Compare commits
22 Commits
v2.0.3
...
v2.5.3-rem
| Author | SHA1 | Date | |
|---|---|---|---|
| a359a4ab8b | |||
| a8c84ecfe4 | |||
| 547fd70650 | |||
| 990a615b87 | |||
| 5352fd9942 | |||
| 66df410826 | |||
| f89c8f3f15 | |||
| cbef7618b3 | |||
| fcc7c5a86e | |||
| bcfc94fa47 | |||
| 90a6761b07 | |||
| a938cf1d42 | |||
| 6f6b3afb5d | |||
| 154ef78f7c | |||
| 792bbb9da3 | |||
| 31e1b32be1 | |||
| 314adaae48 | |||
| 93d3f86c2b | |||
| 04673eaf59 | |||
| d8ffee1950 | |||
| e423579e99 | |||
| 06116f31b3 |
@@ -21,6 +21,7 @@ out/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# Test artifacts / coverage
|
# Test artifacts / coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
|
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boochat
|
||||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||||
PROJECT_ROOT_WHITELIST=/opt
|
PROJECT_ROOT_WHITELIST=/opt
|
||||||
BOOTSTRAP_ROOT=/opt/projects
|
BOOTSTRAP_ROOT=/opt/projects
|
||||||
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
|
|||||||
# point BooCode at a different SearXNG instance.
|
# point BooCode at a different SearXNG instance.
|
||||||
SEARXNG_URL=http://100.114.205.53:8888
|
SEARXNG_URL=http://100.114.205.53:8888
|
||||||
|
|
||||||
|
# Task model: lightweight model for auto-naming, search rewrite, etc.
|
||||||
|
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
|
||||||
|
# with FAST_MODEL when unset.
|
||||||
|
# TASK_MODEL_URL=http://100.90.172.55:7995
|
||||||
|
|
||||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||||
# sessions where the model only needs read-only filesystem access.
|
# sessions where the model only needs read-only filesystem access.
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
|
||||||
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.cursorignore
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -11,3 +16,4 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
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.
|
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.5.2-coder-ux-fixes — 2026-05-29
|
||||||
|
|
||||||
|
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3–v2.5.1 entries were never backfilled and remain absent above.)
|
||||||
|
|
||||||
|
## v2.2.2-xml-placeholder-reject — 2026-05-26
|
||||||
|
|
||||||
|
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
|
||||||
|
|
||||||
|
## v2.2.1-pane-scoped-chats — 2026-05-26
|
||||||
|
|
||||||
|
Follow-up fixes on the v2.2 Paseo provider stack. Pane-scoped chat resolution: `resolveChatId(sql, sessionId, paneId)` reads `sessions.workspace_panes`, requires `pane_id` on coder POST routes, and creates a scoped chat per coder/terminal pane instead of falling back to the session's first open chat (which fused BooCoder writes into the BooChat pane). Client `useWorkspacePanes` seeds new coder/terminal panes with dedicated chats on create, hydrate, and workspace sync; `CoderPane` blocks send until seeded and filters WS frames + `GET /messages?chat_id=` to that chat. External-agent tool UI: new `CoderMessageList` renders BooChat-style `ToolCallLine` timeline (tools before answer text on combined ACP rows). WS user-delta handling replaces content instead of appending (fixes garbled duplicate user messages when optimistic UI met full-body deltas). BooChat inference: `buildMessagesPayload` strips orphan assistant `tool_calls` without matching `tool` rows and skips stray tool rows when the owning assistant turn is incomplete (fixes "Tool results are missing for tool calls" on shared chats with ACP history). Pairs with `v2.2-paseo-providers`.
|
||||||
|
|
||||||
|
## v2.2-paseo-providers — 2026-05-26
|
||||||
|
|
||||||
|
Paseo-equivalent provider stack for BooCoder. Seven providers (boocode, cursor, claude, opencode, goose, qwen, copilot) with snapshot API (`provider-snapshot.ts`, ACP cold probe, per-provider model merge, cursor models from ACP). Frontend `AgentComposerBar` replaces `ProviderPicker` — provider / mode / model / thinking in the coder composer; `SlashCommandPicker` + `useProviderSnapshot` hook. ACP dispatch rewritten (`acp-dispatch.ts`, `acp-stream.ts`, `acp-spawn.ts`, `agent-turn-persist.ts`, `acp-tool-snapshot.ts`) with Paseo merge/stream/persist pattern, inline `PermissionCard` prompts, and `reasoning_delta` WS frames. Agent slash-command hints via ACP `available_commands_update` cached in `agent-commands-cache.ts` + `AgentCommandsHint`. Arena and MCP entry points accept `mode_id` / `thinking_option_id`. SSH helpers removed; all host exec via `host-exec.ts` direct spawn. Server adds coder proxy route + shared skill invoke. New tests: acp-derive, acp-tool-snapshot, cursor-models, provider-commands, provider-snapshot, agents. Docs: `AGENTS.md`, `docs/ARCHITECTURE.md`, openspec `v2-2-paseo-providers`.
|
||||||
|
|
||||||
|
## v2.1.1-roadmap-cleanup — 2026-05-25
|
||||||
|
|
||||||
|
Roadmap reconciliation, README updates, and openspec archive housekeeping. No runtime behavior changes.
|
||||||
|
|
||||||
|
## v2.1.0-provider-picker — 2026-05-25
|
||||||
|
|
||||||
|
Provider picker: BooCoder moves from Docker container to host systemd service (`boocoder.service`). All agent dispatch (ACP + PTY) switches from SSH tunnel to direct `spawn`/`exec` — no more `sshSpawn`/`sshExec`/`sshSpawnWithStdin` (marked `@deprecated`). New provider registry (`provider-registry.ts`) with 5 providers (boocode, opencode, goose, claude, qwen), per-provider model discovery (llama-swap for ACP agents, `~/.qwen/settings.json` for qwen, static for claude), and `agent-probe.ts` runs direct `which`/`exec` instead of SSH. `GET /api/providers` route assembles the provider list with installed status, models, and transport (ACP→PTY fallback if `supports_acp` is false). Frontend `ProviderPicker` component in CoderPane header lets users pick provider/model per message; messages route through `tasks` row for external providers instead of inference enqueue. Smart scroll: `MessageList` only auto-scrolls when user is near bottom (150px threshold). DB schema adds `models`, `label`, `transport` columns to `available_agents`. Bug fixes: `loadContext` SELECT now includes `allowed_read_paths` (cross-repo read grants were silently failing), cap hit sentinel insertion moved before `buildMessagesPayload` call.
|
||||||
|
|
||||||
|
## v2.0.5 — 2026-05-25
|
||||||
|
|
||||||
|
FAST_MODEL routing: optional `FAST_MODEL` env var routes cheaper models (titles, summaries, labeling) to a small model on llama-swap (e.g. `nemotron-nano-4b`) instead of loading the 35B for 20-token calls. Falls back to session model or DEFAULT_MODEL. Tool-use summaries: `runCapHitSummary` now writes the cap_hit sentinel before building the summary payload (bug fix — sentinel was written after, causing it to appear after the summary text in the message list). Qwen Code dispatch: `qwen -p "<task>" --output-format stream-json` via PTY (non-interactive mode, no `--yolo` flag needed). Arena: `POST /api/arena` dispatches the same task to N models/agents in parallel, each with its own task + worktree; `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks winner.
|
||||||
|
|
||||||
|
## v2.0.4-hardening — 2026-05-25
|
||||||
|
|
||||||
|
Path-guard fuzz suite: 25+ traversal-attack tests covering ../ sequences (all depths), encoded traversal (%2e%2e), null byte injection, absolute path escape, prefix-without-separator, backslash traversal, and the full secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc). Plus 5 valid-path positive tests confirming normal writes aren't blocked and 5 edge-case tests (empty, whitespace-only, very long path, triple-dot, multiple slashes). Null-byte and whitespace-only guards added to `resolveWritePath` (previously only checked empty string). DB-integration test skeleton for pending_changes full-cycle (queue create/edit/delete, apply, rewind) gated on DATABASE_URL via `describe.runIf`. Production readiness verified: all services healthy, all builds clean, 57 tests passing (23 existing + 34 new).
|
||||||
|
|
||||||
|
## v2.0.3 — 2026-05-25
|
||||||
|
|
||||||
|
CLI client (`apps/coder/src/cli.ts`, 249 lines) for headless agent interaction. Human inbox view (`human_inbox` view) surfaces tasks in `blocked`/`failed` state. Cost tracking: `tool_cost_stats` view with per-tool 100-call rolling window. `new_task` tool (Boomerang pattern): creates tasks with project context and optional arena contestants. `check_task_status` and `list_tasks` tools for task lifecycle management. Stats routes (`GET /api/stats`) for cost aggregation. Dispatcher extended to support new task states.
|
||||||
|
|
||||||
|
## v2.0.2 — 2026-05-25
|
||||||
|
|
||||||
|
BooCoder MCP server (`mcp-server.ts`, 201 lines) exposing 6 write-capable tools over stdio: `edit_file`, `create_file`, `delete_file`, `view_pending_changes`, `apply_pending`, `rewind`. Registered in `apps/coder/src/index.ts` as an MCP stdio server. Enables external agents (opencode, claude, qwen) to call BooCoder's write tools through the MCP protocol.
|
||||||
|
|
||||||
|
## v2.0.1 — 2026-05-25
|
||||||
|
|
||||||
|
ACP dispatch (`acp-dispatch.ts`, 271 lines): runs ACP-capable agents (opencode, goose) via SSH tunnel wrapping stdio into NDJSON streams for `@agentclientprotocol/sdk` JSON-RPC sessions. PTY dispatch (`pty-dispatch.ts`, 139 lines): runs non-ACP agents (claude, qwen) via SSH with stdin pipe for non-interactive mode. Worktree management (`worktrees.ts`, 118 lines): per-task git worktree creation and cleanup. SSH helper (`ssh.ts`, 126 lines): `sshSpawn`, `sshExec`, `sshSpawnWithStdin` for host command execution. Dispatcher extended to route tasks to ACP vs PTY based on agent capability. Agent probe updated to verify ACP support.
|
||||||
|
|
||||||
|
## v2.0.0-final — 2026-05-25
|
||||||
|
|
||||||
|
Dispatcher (`dispatcher.ts`, 191 lines): task queue with polling loop, Path A (native inference) and Path B (external agent dispatch). Task routes (`tasks.ts`, 138 lines): CRUD for tasks with state transitions. Agent probe (`agent-probe.ts`, 51 lines): startup scan of host for installed agents (opencode, goose, claude, pi, qwen), version detection, ACP capability verification. Schema adds `tasks` table. CLAUDE.md updated with v2.0.0 architecture docs covering BooCoder, DB rename, MCP config, workspace deps.
|
||||||
|
|
||||||
|
## v2.0.0 — 2026-05-25
|
||||||
|
|
||||||
|
BooCoder frontend: `CoderPane.tsx` (432 lines) as a `'coder'` pane type within BooChat's SPA — chat pane + diff pane (pending changes) + session picker. Standalone fallback SPA in `apps/coder/web/` (Vite + React) served at `:9502` directly. Session streaming via `useSessionStream` WS hook. API client with typed endpoints. Workspace pane persistence via `useWorkspacePanes`. Server routes for pending changes (`PATCH/POST /api/coder/sessions/:id/pending`). Verification discipline rules + chat naming from assistant response.
|
||||||
|
|
||||||
|
## v2.0.0-beta — 2026-05-25
|
||||||
|
|
||||||
|
Write tools: `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` — queue in `pending_changes` table, nothing hits disk until applied. `write_guard.ts` validates paths (resolve + prefix-check, no realpath for creates). Inference loop integration via `inference_context.ts` (bridges inference turn state to tool execution). API routes: `messages.ts` (POST /api/coder/sessions/:id/messages), `pending.ts` (GET/POST /api/coder/sessions/:id/pending). WebSocket support (`ws.ts`) for real-time pending changes updates. Tool adapter (`adapter.ts`) converts inference tool calls to tool execution. Write guard tests (115 lines). Server-side inference loop wired to BooCoder tools.
|
||||||
|
|
||||||
|
## v2.0.0-alpha — 2026-05-25
|
||||||
|
|
||||||
|
BooCoder foundation: Docker container (`apps/coder/Dockerfile`), docker-compose service, host env file. Schema: `sessions`, `chats`, `messages`, `pending_changes`, `tasks`, `message_parts` tables. DB renamed from `boocode` to `boochat`. Config module, PostgreSQL connection (porsager/postgres). Initial Fastify server with health endpoint. BOOCODER.md guidance file. Implementation plan (8 phases). Proposal updated with AGENTS.md extensions, Boomerang pattern, observation hooks.
|
||||||
|
|
||||||
|
## v2.0-proposal — 2026-05-24
|
||||||
|
|
||||||
|
v2.0 proposal: BooCoder write tools, pending-changes queue, ACP dispatch, MCP server. Openspec proposal (`proposal.md`, 274 lines) and task breakdown (`tasks.md`, 130 lines) defining the v2.0 feature scope — write-capable coding agent with file operations, external agent dispatch via ACP/PTY, and MCP server for tool exposure.
|
||||||
|
|
||||||
## v1.16.0-codesight-merge — 2026-05-24
|
## v1.16.0-codesight-merge — 2026-05-24
|
||||||
|
|
||||||
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
||||||
|
|||||||
40
CLAUDE.md
40
CLAUDE.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
|
||||||
|
|
||||||
## What is BooCode
|
## What is BooCode
|
||||||
|
|
||||||
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
|
||||||
@@ -66,16 +68,24 @@ Key services:
|
|||||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
|
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||||
|
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||||
|
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||||
|
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||||
|
|
||||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||||
|
|
||||||
### BooCoder (`apps/coder/src/`)
|
### BooCoder (`apps/coder/src/`)
|
||||||
|
|
||||||
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`).
|
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST (Dockerfile builds server → coder).
|
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||||
|
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||||
|
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||||
|
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to `http://boocoder:3000/api/*`. WS connects directly to `:9502`.
|
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||||
|
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||||
|
|
||||||
### Frontend (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
@@ -122,7 +132,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 scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
|
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
|
||||||
|
|
||||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`.
|
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 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 when unset. Set to a small model on llama-swap (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 required on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch.
|
||||||
|
- Arena (v2.0.5): `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 winner.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -133,8 +147,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
|
|||||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||||
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
||||||
|
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
|
||||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||||
@@ -143,8 +158,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
|
|||||||
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
||||||
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
||||||
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
||||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
|
||||||
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
|
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
|
||||||
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
|
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
|
||||||
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
||||||
|
|
||||||
@@ -169,4 +184,13 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
|
|||||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||||
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`.
|
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||||
|
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
||||||
|
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
|
||||||
|
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
|
||||||
|
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||||
|
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||||
|
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||||
|
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||||
|
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||||
|
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||||
|
|||||||
10
CURRENT.md
Normal file
10
CURRENT.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Current focus
|
||||||
|
|
||||||
|
Last updated: 2026-05-26
|
||||||
|
|
||||||
|
- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
|
||||||
|
- **Branch:** `main`
|
||||||
|
- **Blockers:** none
|
||||||
|
- **Last shipped:** `v2.2.2-xml-placeholder-reject`
|
||||||
|
|
||||||
|
Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state.
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
43
README.md
43
README.md
@@ -1,6 +1,10 @@
|
|||||||
# boocode
|
# boocode
|
||||||
|
|
||||||
Self-hosted single-user developer chat app. v1: chat only.
|
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
|
||||||
|
|
||||||
|
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`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)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -13,6 +17,8 @@ Self-hosted single-user developer chat app. v1: chat only.
|
|||||||
|
|
||||||
- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools
|
- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools
|
||||||
- `apps/web` — React frontend; served by Fastify in production, Vite in dev
|
- `apps/web` — React frontend; served by Fastify in production, Vite in dev
|
||||||
|
- `apps/booterm` — Fastify + node-pty + tmux for in-browser terminal panes
|
||||||
|
- `apps/coder` — Fastify write tools + ACP/PTY dispatcher + MCP server (BooCoder)
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ cp .env.example .env
|
|||||||
docker compose up -d boocode_db
|
docker compose up -d boocode_db
|
||||||
|
|
||||||
# run server (port 3000) and web (port 5173) in two shells
|
# run server (port 3000) and web (port 5173) in two shells
|
||||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \
|
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \
|
||||||
LLAMA_SWAP_URL=http://100.101.41.16:8401 \
|
LLAMA_SWAP_URL=http://100.101.41.16:8401 \
|
||||||
pnpm dev:server
|
pnpm dev:server
|
||||||
|
|
||||||
@@ -49,11 +55,32 @@ docker compose up --build -d
|
|||||||
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
|
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
|
||||||
upstream and inject `Remote-User`. Postgres binds loopback only.
|
upstream and inject `Remote-User`. Postgres binds loopback only.
|
||||||
|
|
||||||
## What v1 has
|
BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker:
|
||||||
|
|
||||||
Project sidebar, sessions per project, chat with streaming responses over
|
```bash
|
||||||
WebSocket, four file-read tools scoped to the project root (`view_file`,
|
pnpm -C apps/server build && pnpm -C apps/coder build
|
||||||
`list_dir`, `grep`, `find_files`), and a model picker driven by llama-swap's
|
sudo systemctl restart boocoder
|
||||||
`/v1/models`.
|
curl http://100.114.205.53:9502/api/health
|
||||||
|
```
|
||||||
|
|
||||||
What v1 does not have lives in v2 (terminal pane) and v3 (Coder pane).
|
## Services
|
||||||
|
|
||||||
|
|Service|Port|Description|
|
||||||
|
|---|---|---|
|
||||||
|
|BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
|
||||||
|
|BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes |
|
||||||
|
|BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) |
|
||||||
|
|Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) |
|
||||||
|
|codecontext|internal `:8080`|Code graph sidecar (Docker network only) |
|
||||||
|
|
||||||
|
## What's shipped
|
||||||
|
|
||||||
|
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
@@ -23,5 +23,6 @@
|
|||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/coder/.env.host
Normal file
15
apps/coder/.env.host
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
@@ -29,5 +29,6 @@
|
|||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const ConfigSchema = z.object({
|
|||||||
GITEA_TOKEN: z.string().optional(),
|
GITEA_TOKEN: z.string().optional(),
|
||||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
MCP_CONFIG_PATH: z.string().optional(),
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
|
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||||
|
FAST_MODEL: z.string().optional(),
|
||||||
// SSH access to the host for external agent dispatch (Phase 5)
|
// SSH access to the host for external agent dispatch (Phase 5)
|
||||||
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
|
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
|
||||||
BOOCODER_SSH_USER: z.string().default('samkintop'),
|
BOOCODER_SSH_USER: z.string().default('samkintop'),
|
||||||
|
|||||||
@@ -23,14 +23,20 @@ import { adaptWriteTool } from './services/tools/adapter.js';
|
|||||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||||
// Routes
|
// Routes
|
||||||
import { registerMessageRoutes } from './routes/messages.js';
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerPendingRoutes } from './routes/pending.js';
|
import { registerPendingRoutes } from './routes/pending.js';
|
||||||
import { registerTaskRoutes } from './routes/tasks.js';
|
import { registerTaskRoutes } from './routes/tasks.js';
|
||||||
import { registerInboxRoutes } from './routes/inbox.js';
|
import { registerInboxRoutes } from './routes/inbox.js';
|
||||||
import { registerStatsRoutes } from './routes/stats.js';
|
import { registerStatsRoutes } from './routes/stats.js';
|
||||||
|
import { registerArenaRoutes } from './routes/arena.js';
|
||||||
|
import { registerProviderRoutes } from './routes/providers.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// MCP mode: stdio transport, no HTTP server
|
// MCP mode: stdio transport, no HTTP server
|
||||||
@@ -70,6 +76,33 @@ async function main() {
|
|||||||
// Broker: in-memory pub/sub for session + user channel streaming.
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
const broker = createBroker(app.log);
|
const broker = createBroker(app.log);
|
||||||
|
|
||||||
|
setPermissionHooks({
|
||||||
|
onPrompt: async (prompt) => {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running'
|
||||||
|
`;
|
||||||
|
broker.publishFrame(prompt.sessionId, {
|
||||||
|
type: 'permission_requested',
|
||||||
|
task_id: prompt.taskId,
|
||||||
|
session_id: prompt.sessionId,
|
||||||
|
kind: prompt.kind,
|
||||||
|
tool_title: prompt.toolTitle,
|
||||||
|
...(prompt.input ? { input: prompt.input } : {}),
|
||||||
|
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||||
|
} as WsFrame);
|
||||||
|
},
|
||||||
|
onResolved: async (taskId, sessionId) => {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked'
|
||||||
|
`;
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'permission_resolved',
|
||||||
|
task_id: taskId,
|
||||||
|
session_id: sessionId,
|
||||||
|
} as WsFrame);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// --- Tool registry extension ---
|
// --- Tool registry extension ---
|
||||||
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||||
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||||
@@ -132,6 +165,16 @@ async function main() {
|
|||||||
// Phase 4: probe available agents on startup
|
// Phase 4: probe available agents on startup
|
||||||
await probeAgents(sql, app.log);
|
await probeAgents(sql, app.log);
|
||||||
|
|
||||||
|
// Warm provider snapshot in background (ACP cold probes + model merges)
|
||||||
|
void getProviderSnapshot(sql, config, homedir(), true)
|
||||||
|
.then((entries) => persistProbedModels(sql, entries, app.log))
|
||||||
|
.catch((err) => {
|
||||||
|
app.log.warn(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
'provider-snapshot: warm failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||||
dispatcher.start();
|
dispatcher.start();
|
||||||
@@ -139,10 +182,13 @@ async function main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
registerSkillRoutes(app, sql, broker, inferenceApi);
|
||||||
registerPendingRoutes(app, sql);
|
registerPendingRoutes(app, sql);
|
||||||
registerTaskRoutes(app, sql, inferenceApi);
|
registerTaskRoutes(app, sql, inferenceApi);
|
||||||
registerInboxRoutes(app, sql);
|
registerInboxRoutes(app, sql);
|
||||||
registerStatsRoutes(app, sql);
|
registerStatsRoutes(app, sql);
|
||||||
|
registerArenaRoutes(app, sql);
|
||||||
|
registerProviderRoutes(app, sql, config);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Serve static frontend (built web app). In production, the dist/ is
|
// Serve static frontend (built web app). In production, the dist/ is
|
||||||
|
|||||||
136
apps/coder/src/routes/arena.ts
Normal file
136
apps/coder/src/routes/arena.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateArenaBody = z.object({
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
input: z.string().min(1).max(64_000),
|
||||||
|
contestants: z.array(ContestantSchema).min(2).max(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
agent: string | null;
|
||||||
|
model: string | null;
|
||||||
|
mode_id: string | null;
|
||||||
|
thinking_option_id: string | null;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 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!);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid arena_id format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'arena not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { arena_id, tasks };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/coder/src/routes/chat-resolve.ts
Normal file
81
apps/coder/src/routes/chat-resolve.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
interface WorkspacePaneRow {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
chatId?: string;
|
||||||
|
chatIds?: string[];
|
||||||
|
activeChatIdx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatNameForKind(kind: string): string {
|
||||||
|
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
||||||
|
if (kind === 'terminal') return 'Terminal';
|
||||||
|
return 'Chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeChatIdForPane(pane: WorkspacePaneRow): string | undefined {
|
||||||
|
const chatIds = pane.chatIds ?? [];
|
||||||
|
const idx = pane.activeChatIdx ?? 0;
|
||||||
|
if (idx >= 0 && idx < chatIds.length) return chatIds[idx];
|
||||||
|
return pane.chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the active chat for a workspace pane; auto-seed when empty. */
|
||||||
|
export async function resolveChatId(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
paneId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return sql.begin(async (tx) => {
|
||||||
|
const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>`
|
||||||
|
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) return null;
|
||||||
|
|
||||||
|
const panes = sessionRows[0]!.workspace_panes ?? [];
|
||||||
|
const paneIdx = panes.findIndex((p) => p.id === paneId);
|
||||||
|
if (paneIdx < 0) return null;
|
||||||
|
|
||||||
|
const pane = panes[paneIdx]!;
|
||||||
|
const existingChatId = activeChatIdForPane(pane);
|
||||||
|
if (existingChatId) {
|
||||||
|
const chatRows = await tx<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats
|
||||||
|
WHERE id = ${existingChatId}
|
||||||
|
AND session_id = ${sessionId}
|
||||||
|
AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length > 0) return existingChatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newChat] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, ${chatNameForKind(pane.kind)}, 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (!newChat) return null;
|
||||||
|
|
||||||
|
const nextChatIds = [...(pane.chatIds ?? []), newChat.id];
|
||||||
|
const nextActiveIdx = nextChatIds.length - 1;
|
||||||
|
const nextPanes = panes.map((p, i) =>
|
||||||
|
i === paneIdx
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
chatIds: nextChatIds,
|
||||||
|
activeChatIdx: nextActiveIdx,
|
||||||
|
chatId: newChat.id,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx`
|
||||||
|
UPDATE sessions
|
||||||
|
SET workspace_panes = ${tx.json(nextPanes as never)},
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return newChat.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,10 +3,43 @@ import { z } from 'zod';
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
import { resolveChatId } from './chat-resolve.js';
|
||||||
|
|
||||||
|
const AnswerUserInputBody = z.object({
|
||||||
|
tool_call_id: z.string().min(1),
|
||||||
|
answers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
selected_options: z.array(z.string()),
|
||||||
|
free_text: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AskUserInputArgs = z.object({
|
||||||
|
questions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
type: z.enum(['single_select', 'multi_select']),
|
||||||
|
options: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
const SendBody = z.object({
|
const SendBody = z.object({
|
||||||
content: z.string().min(1).max(64_000),
|
content: z.string().min(1).max(64_000),
|
||||||
chat_id: z.string().uuid(),
|
pane_id: z.string().min(1).max(200),
|
||||||
|
chat_id: z.string().uuid().optional(),
|
||||||
|
provider: 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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface InferenceApi {
|
interface InferenceApi {
|
||||||
@@ -15,12 +48,100 @@ interface InferenceApi {
|
|||||||
hasActive: (chatId: string) => boolean;
|
hasActive: (chatId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageRow {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: string | null;
|
||||||
|
status: string | null;
|
||||||
|
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
|
||||||
|
tool_results: {
|
||||||
|
tool_call_id: string;
|
||||||
|
output: unknown;
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
reasoning_parts: Array<{ text?: string }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCoderMessageRow(row: MessageRow) {
|
||||||
|
if (row.role === 'tool') {
|
||||||
|
if (!row.tool_results?.tool_call_id) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
role: 'tool' as const,
|
||||||
|
tool_results: row.tool_results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tool_calls = row.tool_calls?.map((tc) => ({
|
||||||
|
id: tc.id,
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: JSON.stringify(tc.args ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
role: row.role as 'user' | 'assistant' | 'system',
|
||||||
|
content: row.content ?? '',
|
||||||
|
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
|
||||||
|
...(reasoningText ? { reasoning_text: reasoningText } : {}),
|
||||||
|
...(tool_calls?.length ? { tool_calls } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function registerMessageRoutes(
|
export function registerMessageRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
broker: Broker,
|
broker: Broker,
|
||||||
inference: InferenceApi,
|
inference: InferenceApi,
|
||||||
): void {
|
): void {
|
||||||
|
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
|
||||||
|
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
|
||||||
|
'/api/sessions/:sessionId/messages',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const chatId = req.query.chat_id;
|
||||||
|
const sessionRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
const chatRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats
|
||||||
|
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found or not open in this session' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = chatId
|
||||||
|
? await sql<MessageRow[]>`
|
||||||
|
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`
|
||||||
|
: await sql<MessageRow[]>`
|
||||||
|
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/messages',
|
'/api/sessions/:sessionId/messages',
|
||||||
@@ -32,70 +153,225 @@ export function registerMessageRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const { content, chat_id: chatId } = parsed.data;
|
const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
|
||||||
|
parsed.data;
|
||||||
|
const isExternal = provider && provider !== 'boocode';
|
||||||
|
|
||||||
// Validate session exists
|
// Validate session exists
|
||||||
const sessionRows = await sql<{ id: string }[]>`
|
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) {
|
if (sessionRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate chat belongs to session and is open
|
const resolved = await resolveChatId(sql, sessionId, pane_id);
|
||||||
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
if (!resolved) {
|
||||||
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
reply.code(404);
|
||||||
|
return { error: 'pane not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let chatId = resolved;
|
||||||
|
if (explicitChatId) {
|
||||||
|
const chatRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
|
||||||
`;
|
`;
|
||||||
if (chatRows.length === 0) {
|
if (chatRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'chat not found or not open in this session' };
|
return { error: 'chat not found or not open in this session' };
|
||||||
}
|
}
|
||||||
|
chatId = explicitChatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExternal) {
|
||||||
// Reject if inference is already running on this chat
|
// Reject if inference is already running on this chat
|
||||||
if (inference.hasActive(chatId)) {
|
if (inference.hasActive(chatId)) {
|
||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'inference already running on this chat' };
|
return { error: 'inference already running on this chat' };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create user message + streaming assistant row in a transaction
|
// Create user message
|
||||||
const result = await sql.begin(async (tx) => {
|
const [userMsg] = await sql<{ id: string }[]>`
|
||||||
const [userMsg] = await tx<{ id: string }[]>`
|
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
|
||||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
|
||||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publish user message frames so WS subscribers see it immediately
|
// Publish user message frames
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: result.user_message_id,
|
message_id: userMsg!.id,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
} as unknown as WsFrame);
|
} as unknown as WsFrame);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: result.user_message_id,
|
message_id: userMsg!.id,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content,
|
content,
|
||||||
} as unknown as WsFrame);
|
} as unknown as WsFrame);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: result.user_message_id,
|
message_id: userMsg!.id,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
} as unknown as WsFrame);
|
} as unknown as WsFrame);
|
||||||
|
|
||||||
// Enqueue inference — the runner will stream assistant deltas via broker
|
if (isExternal) {
|
||||||
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
// External provider: create a task for the dispatcher
|
||||||
|
const projectId = sessionRows[0]!.project_id;
|
||||||
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||||
|
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
reply.code(202);
|
||||||
|
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native provider: create streaming assistant row + enqueue inference
|
||||||
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/chats/:id/answer_user_input — answer a pending ask_user_input
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/answer_user_input',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = AnswerUserInputBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { tool_call_id, answers } = parsed.data;
|
||||||
|
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat_not_found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
const callerRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'assistant'
|
||||||
|
AND p.kind = 'tool_call'
|
||||||
|
AND p.payload->>'id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!callerRows[0]) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id' };
|
||||||
|
}
|
||||||
|
const foundCall = callerRows[0].payload;
|
||||||
|
if (foundCall.name !== 'ask_user_input') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'tool_call_not_ask_user_input' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
||||||
|
if (!argsParsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||||
|
}
|
||||||
|
const questions = argsParsed.data.questions;
|
||||||
|
if (answers.length !== questions.length) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}` };
|
||||||
|
}
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!;
|
||||||
|
const a = answers[i]!;
|
||||||
|
for (const sel of a.selected_options) {
|
||||||
|
if (!q.options.includes(sel)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} option not in question: ${sel}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} multi on single_select` };
|
||||||
|
}
|
||||||
|
if (a.selected_options.length === 0 && (!a.free_text || !a.free_text.trim())) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolRows = await sql<{
|
||||||
|
message_id: string;
|
||||||
|
payload: { tool_call_id: string; output: unknown };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.message_id, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chat.id}
|
||||||
|
AND m.role = 'tool'
|
||||||
|
AND p.kind = 'tool_result'
|
||||||
|
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!toolRows[0]) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||||
|
}
|
||||||
|
if (toolRows[0].payload?.output !== null) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'tool_call_already_answered' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerSet = { answers };
|
||||||
|
const newToolResults = { tool_call_id, output: answerSet, truncated: false };
|
||||||
|
const toolMessageId = toolRows[0].message_id;
|
||||||
|
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: result.tool_message_id,
|
||||||
|
tool_call_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
output: answerSet,
|
||||||
|
truncated: false,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
inference.enqueue(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
reply.code(202);
|
reply.code(202);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import {
|
import {
|
||||||
listPending,
|
listPending,
|
||||||
@@ -6,7 +7,14 @@ import {
|
|||||||
applyAll,
|
applyAll,
|
||||||
rejectOne,
|
rejectOne,
|
||||||
rewindOne,
|
rewindOne,
|
||||||
|
queueCreate,
|
||||||
} from '../services/pending_changes.js';
|
} from '../services/pending_changes.js';
|
||||||
|
import { WriteGuardError } from '../services/write_guard.js';
|
||||||
|
|
||||||
|
const CreateBody = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve project root from a session's project path.
|
* Resolve project root from a session's project path.
|
||||||
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
|
||||||
|
// (manual create from the RightRail file browser; no inference involved).
|
||||||
|
// queueCreate runs resolveWritePath internally, so a path that escapes the
|
||||||
|
// project root or hits a secret file throws WriteGuardError → 422 with the
|
||||||
|
// guard message. Mirrors the { error } 404 shape used by the other routes
|
||||||
|
// and the 422 status used by apply/rewind on failure.
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending/create',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const parsed = CreateBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const change = await queueCreate(
|
||||||
|
sql,
|
||||||
|
sessionId,
|
||||||
|
null,
|
||||||
|
parsed.data.file_path,
|
||||||
|
parsed.data.content,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return change;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof WriteGuardError) {
|
||||||
|
reply.code(422);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/pending/apply',
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
|||||||
17
apps/coder/src/routes/providers.ts
Normal file
17
apps/coder/src/routes/providers.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
||||||
|
|
||||||
|
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||||
|
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||||
|
const cwd = req.query.cwd;
|
||||||
|
return getProviderSnapshot(sql, config, cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||||
|
clearProviderSnapshotCache();
|
||||||
|
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||||
|
return { refreshed: entries.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
93
apps/coder/src/routes/skills.ts
Normal file
93
apps/coder/src/routes/skills.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
import { getSkillBody } from '@boocode/server/skills';
|
||||||
|
import {
|
||||||
|
buildSkillInvokeSyntheticFrames,
|
||||||
|
buildSkillInvokeUserFrames,
|
||||||
|
DEFAULT_SKILL_USER_MESSAGE,
|
||||||
|
runSkillInvokeTransaction,
|
||||||
|
} from '@boocode/server/skill-invoke';
|
||||||
|
import { resolveChatId } from './chat-resolve.js';
|
||||||
|
|
||||||
|
const SkillInvokeBody = z.object({
|
||||||
|
pane_id: z.string().min(1).max(200),
|
||||||
|
skill_name: z.string().min(1),
|
||||||
|
user_message: z.string().max(64_000).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface InferenceApi {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
|
hasActive: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSkillRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
inference: InferenceApi,
|
||||||
|
): void {
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/skill_invoke',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = SkillInvokeBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const { pane_id, skill_name } = parsed.data;
|
||||||
|
const sessionRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatId = await resolveChatId(sql, sessionId, pane_id);
|
||||||
|
if (!chatId) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pane not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inference.hasActive(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'inference already running on this chat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userText = parsed.data.user_message?.trim()
|
||||||
|
? parsed.data.user_message
|
||||||
|
: DEFAULT_SKILL_USER_MESSAGE;
|
||||||
|
|
||||||
|
const body = await getSkillBody(skill_name);
|
||||||
|
if (body === null) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
skillName: skill_name,
|
||||||
|
skillBody: body,
|
||||||
|
userText,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) {
|
||||||
|
broker.publishFrame(sessionId, frame as WsFrame);
|
||||||
|
}
|
||||||
|
for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) {
|
||||||
|
broker.publishFrame(sessionId, frame as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
|
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
|
||||||
|
import { getTaskCommands } from '../services/agent-commands-cache.js';
|
||||||
|
|
||||||
interface InferenceApi {
|
interface InferenceApi {
|
||||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
@@ -11,6 +13,13 @@ const CreateBody = z.object({
|
|||||||
input: z.string().min(1).max(64_000),
|
input: z.string().min(1).max(64_000),
|
||||||
agent: z.string().max(100).optional(),
|
agent: z.string().max(100).optional(),
|
||||||
model: z.string().max(200).optional(),
|
model: z.string().max(200).optional(),
|
||||||
|
mode_id: z.string().max(200).optional(),
|
||||||
|
thinking_option_id: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PermissionBody = z.object({
|
||||||
|
option_id: z.string().max(200).nullable(),
|
||||||
|
updated_input: z.record(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListQuery = z.object({
|
const ListQuery = z.object({
|
||||||
@@ -27,11 +36,11 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project_id, input, agent, model } = parsed.data;
|
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
|
||||||
|
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
|
||||||
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null})
|
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -111,13 +120,15 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = rows[0]!;
|
const task = rows[0]!;
|
||||||
if (task.state !== 'pending' && task.state !== 'running') {
|
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
|
||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: `cannot cancel task in state '${task.state}'` };
|
return { error: `cannot cancel task in state '${task.state}'` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelPendingPermission(taskId);
|
||||||
|
|
||||||
// If running, try to cancel inference
|
// If running, try to cancel inference
|
||||||
if (task.state === 'running' && task.session_id) {
|
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
|
||||||
// Find active chat in the task's session
|
// Find active chat in the task's session
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
||||||
@@ -130,9 +141,45 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
|||||||
await sql`
|
await sql`
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||||
WHERE id = ${taskId} AND state IN ('pending', 'running')
|
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return { cancelled: true };
|
return { cancelled: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/tasks/:id/permission — pending permission prompt (if any)
|
||||||
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
||||||
|
const prompt = getPendingPermission(req.params.id);
|
||||||
|
if (!prompt) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'no pending permission' };
|
||||||
|
}
|
||||||
|
return prompt;
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tasks/:id/permission — respond to a pending permission prompt
|
||||||
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
||||||
|
const parsed = PermissionBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'no pending permission' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
|
||||||
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
|
||||||
|
const commands = getTaskCommands(req.params.id);
|
||||||
|
if (!commands?.length) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'no commands cached' };
|
||||||
|
}
|
||||||
|
return { taskId: req.params.id, commands };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function registerWebSocket(
|
|||||||
|
|
||||||
// Send snapshot of existing messages so client can hydrate
|
// Send snapshot of existing messages so client can hydrate
|
||||||
const messages = await sql<Record<string, unknown>[]>`
|
const messages = await sql<Record<string, unknown>[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
ended_at TIMESTAMPTZ,
|
ended_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||||
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
|
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS available_agents (
|
CREATE TABLE IF NOT EXISTS available_agents (
|
||||||
@@ -46,6 +46,47 @@ CREATE TABLE IF NOT EXISTS available_agents (
|
|||||||
-- v2.0.0 Phase 4: link tasks to their inference sessions.
|
-- v2.0.0 Phase 4: link tasks to their inference sessions.
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
|
||||||
|
|
||||||
|
-- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column.
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk;
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk
|
||||||
|
CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'));
|
||||||
|
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
|
-- Human inbox: tasks needing attention
|
||||||
CREATE OR REPLACE VIEW human_inbox AS
|
CREATE OR REPLACE VIEW human_inbox AS
|
||||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||||
|
|
||||||
|
-- v2.1.0: provider picker — extend available_agents with model discovery.
|
||||||
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||||
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||||
|
|
||||||
|
-- v2.2.0: Paseo-style session config on tasks.
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
-- 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
|
||||||
|
-- app-code drift. Idempotent: re-applied on every startup.
|
||||||
|
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('tasks_new', '');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
|
||||||
|
CREATE TRIGGER tasks_notify_new
|
||||||
|
AFTER INSERT ON tasks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_tasks_new();
|
||||||
|
|||||||
154
apps/coder/src/services/__tests__/acp-derive.test.ts
Normal file
154
apps/coder/src/services/__tests__/acp-derive.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { SessionConfigOption } from '@agentclientprotocol/sdk';
|
||||||
|
import {
|
||||||
|
deriveModesFromACP,
|
||||||
|
deriveModelDefinitionsFromACP,
|
||||||
|
findThoughtLevelConfigId,
|
||||||
|
} from '../acp-derive.js';
|
||||||
|
|
||||||
|
describe('deriveModesFromACP', () => {
|
||||||
|
it('prefers modeState.availableModes when present', () => {
|
||||||
|
const { modes, currentModeId } = deriveModesFromACP(
|
||||||
|
[{ id: 'fallback', label: 'Fallback' }],
|
||||||
|
{
|
||||||
|
currentModeId: 'plan',
|
||||||
|
availableModes: [
|
||||||
|
{ id: 'plan', name: 'Plan', description: 'Read-only planning' },
|
||||||
|
{ id: 'code', name: 'Code' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(modes).toEqual([
|
||||||
|
{ id: 'plan', label: 'Plan', description: 'Read-only planning' },
|
||||||
|
{ id: 'code', label: 'Code', description: undefined },
|
||||||
|
]);
|
||||||
|
expect(currentModeId).toBe('plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to configOptions mode select', () => {
|
||||||
|
const configOptions: SessionConfigOption[] = [
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
id: 'mode',
|
||||||
|
category: 'mode',
|
||||||
|
currentValue: 'auto',
|
||||||
|
options: [
|
||||||
|
{ value: 'auto', name: 'Auto' },
|
||||||
|
{ value: 'manual', name: 'Manual', description: 'Ask first' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { modes, currentModeId } = deriveModesFromACP([], null, configOptions);
|
||||||
|
|
||||||
|
expect(modes).toEqual([
|
||||||
|
{ id: 'auto', label: 'Auto', description: undefined },
|
||||||
|
{ id: 'manual', label: 'Manual', description: 'Ask first' },
|
||||||
|
]);
|
||||||
|
expect(currentModeId).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses static fallback when no ACP mode data', () => {
|
||||||
|
const fallback = [{ id: 'default', label: 'Default' }];
|
||||||
|
const { modes, currentModeId } = deriveModesFromACP(fallback, null, null);
|
||||||
|
|
||||||
|
expect(modes).toEqual(fallback);
|
||||||
|
expect(currentModeId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveModelDefinitionsFromACP', () => {
|
||||||
|
it('maps availableModels with thought_level options', () => {
|
||||||
|
const configOptions: SessionConfigOption[] = [
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
id: 'thought',
|
||||||
|
category: 'thought_level',
|
||||||
|
currentValue: 'medium',
|
||||||
|
options: [
|
||||||
|
{ value: 'low', name: 'Low' },
|
||||||
|
{ value: 'medium', name: 'Medium' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const models = deriveModelDefinitionsFromACP(
|
||||||
|
{
|
||||||
|
currentModelId: 'gpt-4',
|
||||||
|
availableModels: [
|
||||||
|
{ modelId: 'gpt-4', name: 'GPT-4' },
|
||||||
|
{ modelId: 'gpt-4-mini', name: 'Mini', description: 'Cheaper' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
configOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{
|
||||||
|
id: 'gpt-4',
|
||||||
|
label: 'GPT-4',
|
||||||
|
description: undefined,
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: 'low', label: 'Low', isDefault: false },
|
||||||
|
{ id: 'medium', label: 'Medium', isDefault: true },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-4-mini',
|
||||||
|
label: 'Mini',
|
||||||
|
description: 'Cheaper',
|
||||||
|
isDefault: false,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: 'low', label: 'Low', isDefault: false },
|
||||||
|
{ id: 'medium', label: 'Medium', isDefault: true },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: 'medium',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to model select config when no availableModels', () => {
|
||||||
|
const configOptions: SessionConfigOption[] = [
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
id: 'model',
|
||||||
|
category: 'model',
|
||||||
|
currentValue: 'sonnet',
|
||||||
|
options: [
|
||||||
|
{ value: 'sonnet', name: 'Sonnet' },
|
||||||
|
{ value: 'opus', name: 'Opus' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const models = deriveModelDefinitionsFromACP(null, configOptions);
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'sonnet', label: 'Sonnet', isDefault: true, defaultThinkingOptionId: undefined },
|
||||||
|
{ id: 'opus', label: 'Opus', isDefault: false, defaultThinkingOptionId: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findThoughtLevelConfigId', () => {
|
||||||
|
it('returns thought_level select id', () => {
|
||||||
|
const configOptions: SessionConfigOption[] = [
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
id: 'effort',
|
||||||
|
category: 'thought_level',
|
||||||
|
currentValue: 'high',
|
||||||
|
options: [{ value: 'high', name: 'High' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findThoughtLevelConfigId(configOptions)).toBe('effort');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when missing', () => {
|
||||||
|
expect(findThoughtLevelConfigId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts
Normal file
66
apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
mergeToolSnapshot,
|
||||||
|
mapToolLifecycleStatus,
|
||||||
|
snapshotToWireToolCall,
|
||||||
|
synthesizeCanceledSnapshots,
|
||||||
|
} from '../acp-tool-snapshot.js';
|
||||||
|
|
||||||
|
describe('mergeToolSnapshot', () => {
|
||||||
|
it('preserves stable toolCallId across updates', () => {
|
||||||
|
const first = mergeToolSnapshot('tc-1', {
|
||||||
|
toolCallId: 'tc-1',
|
||||||
|
title: 'Read file',
|
||||||
|
kind: 'read',
|
||||||
|
status: 'in_progress',
|
||||||
|
rawInput: { path: 'foo.ts' },
|
||||||
|
});
|
||||||
|
const merged = mergeToolSnapshot(
|
||||||
|
'tc-1',
|
||||||
|
{
|
||||||
|
toolCallId: 'tc-1',
|
||||||
|
title: 'Read file',
|
||||||
|
status: 'completed',
|
||||||
|
rawOutput: { content: 'hello' },
|
||||||
|
},
|
||||||
|
first,
|
||||||
|
);
|
||||||
|
expect(merged.toolCallId).toBe('tc-1');
|
||||||
|
expect(merged.rawInput).toEqual({ path: 'foo.ts' });
|
||||||
|
expect(merged.status).toBe('completed');
|
||||||
|
expect(merged.rawOutput).toEqual({ content: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapshotToWireToolCall', () => {
|
||||||
|
it('embeds ACP lifecycle meta for UI merge', () => {
|
||||||
|
const wire = snapshotToWireToolCall({
|
||||||
|
toolCallId: 'tc-42',
|
||||||
|
title: 'Edit',
|
||||||
|
kind: 'edit',
|
||||||
|
status: 'completed',
|
||||||
|
rawInput: { path: 'a.ts' },
|
||||||
|
rawOutput: 'ok',
|
||||||
|
});
|
||||||
|
expect(wire.id).toBe('tc-42');
|
||||||
|
expect(wire.name).toBe('edit');
|
||||||
|
expect(wire.args._acp).toMatchObject({ status: 'completed', title: 'Edit', output: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps synthesized cancel to canceled lifecycle', () => {
|
||||||
|
const [canceled] = synthesizeCanceledSnapshots([
|
||||||
|
{ toolCallId: 'tc-1', title: 'Run', status: 'in_progress' },
|
||||||
|
]);
|
||||||
|
const wire = snapshotToWireToolCall(canceled!);
|
||||||
|
expect(wire.args._acp).toMatchObject({ status: 'canceled' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapToolLifecycleStatus', () => {
|
||||||
|
it('maps ACP statuses to UI lifecycle', () => {
|
||||||
|
expect(mapToolLifecycleStatus('completed')).toBe('completed');
|
||||||
|
expect(mapToolLifecycleStatus('failed')).toBe('failed');
|
||||||
|
expect(mapToolLifecycleStatus('in_progress')).toBe('running');
|
||||||
|
expect(mapToolLifecycleStatus(undefined, 'canceled')).toBe('canceled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { readFile, rm, mkdir } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { queueCreate, queueEdit, queueDelete, applyOne, rewindOne, listPending } from '../pending_changes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for the full pending-changes lifecycle.
|
||||||
|
* Requires DATABASE_URL env var pointing to a running postgres instance.
|
||||||
|
* Skips cleanly when DATABASE_URL is not set.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/coder test
|
||||||
|
*/
|
||||||
|
describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () => {
|
||||||
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
const testDir = '/tmp/boocode-pending-changes-test-' + Date.now();
|
||||||
|
const projectRoot = testDir;
|
||||||
|
const testSessionId = '00000000-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
const schemaPath = resolve(__dirname, '../../schema.sql');
|
||||||
|
const ddl = readFileSync(schemaPath, 'utf8');
|
||||||
|
await sql.unsafe(ddl);
|
||||||
|
|
||||||
|
// Create temp project directory
|
||||||
|
await mkdir(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup test data
|
||||||
|
await sql`DELETE FROM pending_changes WHERE session_id = ${testSessionId}`;
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
// Remove temp directory
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queueCreate → listPending → applyOne → verify file exists', async () => {
|
||||||
|
const change = await queueCreate(sql, testSessionId, null, 'hello.txt', 'hello world', projectRoot);
|
||||||
|
expect(change.status).toBe('pending');
|
||||||
|
expect(change.operation).toBe('create');
|
||||||
|
|
||||||
|
const pending = await listPending(sql, testSessionId);
|
||||||
|
expect(pending.some((p) => p.id === change.id)).toBe(true);
|
||||||
|
|
||||||
|
const result = await applyOne(sql, change.id, projectRoot);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const content = await readFile(resolve(testDir, 'hello.txt'), 'utf8');
|
||||||
|
expect(content).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queueEdit → apply → verify content changed', async () => {
|
||||||
|
// Setup: create a file first
|
||||||
|
const createChange = await queueCreate(sql, testSessionId, null, 'editable.txt', 'original content here', projectRoot);
|
||||||
|
await applyOne(sql, createChange.id, projectRoot);
|
||||||
|
|
||||||
|
// Queue an edit
|
||||||
|
const editChange = await queueEdit(sql, testSessionId, null, 'editable.txt', 'original', 'modified', projectRoot);
|
||||||
|
expect(editChange.operation).toBe('edit');
|
||||||
|
|
||||||
|
const result = await applyOne(sql, editChange.id, projectRoot);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const content = await readFile(resolve(testDir, 'editable.txt'), 'utf8');
|
||||||
|
expect(content).toBe('modified content here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queueDelete → apply → verify file gone', async () => {
|
||||||
|
// Setup: create a file
|
||||||
|
const createChange = await queueCreate(sql, testSessionId, null, 'deleteme.txt', 'goodbye', projectRoot);
|
||||||
|
await applyOne(sql, createChange.id, projectRoot);
|
||||||
|
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(true);
|
||||||
|
|
||||||
|
// Queue a delete
|
||||||
|
const deleteChange = await queueDelete(sql, testSessionId, null, 'deleteme.txt', projectRoot);
|
||||||
|
const result = await applyOne(sql, deleteChange.id, projectRoot);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewindOne → verify reverted', async () => {
|
||||||
|
// Setup: create and apply a file
|
||||||
|
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||||
|
await applyOne(sql, createChange.id, projectRoot);
|
||||||
|
|
||||||
|
// Rewind the create (should delete the file)
|
||||||
|
const result = await rewindOne(sql, createChange.id, projectRoot);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(existsSync(resolve(testDir, 'rewindable.txt'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
apps/coder/src/services/__tests__/provider-commands.test.ts
Normal file
26
apps/coder/src/services/__tests__/provider-commands.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provider-commands.js';
|
||||||
|
|
||||||
|
describe('provider-commands', () => {
|
||||||
|
it('defines commands for every external harness', () => {
|
||||||
|
for (const name of ['claude', 'opencode', 'goose', 'qwen']) {
|
||||||
|
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boocode uses frontend skills — empty manifest', () => {
|
||||||
|
expect(getManifestCommands('boocode')).toEqual([]);
|
||||||
|
expect(PROVIDER_COMMANDS.boocode).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mergeCommands dedupes by name with later override', () => {
|
||||||
|
const merged = mergeCommands(
|
||||||
|
[{ name: 'help', description: 'a' }],
|
||||||
|
[{ name: 'help', description: 'b' }, { name: 'clear' }],
|
||||||
|
);
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{ name: 'clear' },
|
||||||
|
{ name: 'help', description: 'b' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/coder/src/services/__tests__/provider-snapshot.test.ts
Normal file
168
apps/coder/src/services/__tests__/provider-snapshot.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
mergeModels,
|
||||||
|
prefixLlamaSwapModels,
|
||||||
|
clearProviderSnapshotCache,
|
||||||
|
getProviderSnapshot,
|
||||||
|
} from '../provider-snapshot.js';
|
||||||
|
|
||||||
|
vi.mock('../acp-probe.js', () => ({
|
||||||
|
probeAcpProvider: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { probeAcpProvider } from '../acp-probe.js';
|
||||||
|
|
||||||
|
const mockProbe = vi.mocked(probeAcpProvider);
|
||||||
|
|
||||||
|
function mockSql(agents: Array<{
|
||||||
|
name: string;
|
||||||
|
install_path: string | null;
|
||||||
|
supports_acp: boolean;
|
||||||
|
models: Array<{ id: string; label: string }> | null;
|
||||||
|
label: string | null;
|
||||||
|
transport: string | null;
|
||||||
|
}>) {
|
||||||
|
return vi.fn((strings: TemplateStringsArray) => {
|
||||||
|
const query = strings.join('');
|
||||||
|
if (query.includes('FROM available_agents')) {
|
||||||
|
return Promise.resolve(agents);
|
||||||
|
}
|
||||||
|
if (query.includes('UPDATE available_agents')) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as unknown as import('../db.js').Sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||||
|
} as import('../config.js').Config;
|
||||||
|
|
||||||
|
describe('prefixLlamaSwapModels', () => {
|
||||||
|
it('prefixes bare ids', () => {
|
||||||
|
expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([
|
||||||
|
{ id: 'llama-swap/qwen3', label: 'qwen3' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves already-prefixed ids unchanged', () => {
|
||||||
|
expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([
|
||||||
|
{ id: 'llama-swap/qwen3', label: 'qwen3' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeModels', () => {
|
||||||
|
it('dedupes by id preserving first occurrence', () => {
|
||||||
|
const merged = mergeModels(
|
||||||
|
[{ id: 'a', label: 'A' }],
|
||||||
|
[{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }],
|
||||||
|
);
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{ id: 'a', label: 'A' },
|
||||||
|
{ id: 'b', label: 'B' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProviderSnapshot', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearProviderSnapshotCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges opencode ACP models with prefixed llama-swap models', async () => {
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
|
||||||
|
modes: [{ id: 'build', label: 'Build' }],
|
||||||
|
defaultModeId: 'build',
|
||||||
|
commands: [{ name: 'custom', description: 'From ACP probe' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'opencode',
|
||||||
|
install_path: '/usr/bin/opencode',
|
||||||
|
supports_acp: true,
|
||||||
|
models: null,
|
||||||
|
label: 'OpenCode',
|
||||||
|
transport: 'acp',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const opencode = entries.find((e) => e.name === 'opencode');
|
||||||
|
|
||||||
|
expect(opencode?.models.map((m) => m.id)).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'llama-swap/local-model',
|
||||||
|
'llama-swap/existing',
|
||||||
|
]);
|
||||||
|
expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||||
|
expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines qwen-shaped probe and settings model lists via mergeModels', () => {
|
||||||
|
const merged = mergeModels(
|
||||||
|
[{ id: 'qwen-probed', label: 'Qwen Probed' }],
|
||||||
|
[{ id: 'from-settings', label: 'from-settings' }],
|
||||||
|
);
|
||||||
|
expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached entries on second call within TTL', async () => {
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'm1', label: 'M1' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: null,
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||||
|
|
||||||
|
expect(mockProbe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches claude thinking options', async () => {
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'claude',
|
||||||
|
install_path: '/usr/bin/claude',
|
||||||
|
supports_acp: false,
|
||||||
|
models: [{ id: 'claude-sonnet', label: 'Sonnet' }],
|
||||||
|
label: 'Claude Code',
|
||||||
|
transport: 'pty',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const claude = entries.find((e) => e.name === 'claude');
|
||||||
|
|
||||||
|
expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||||
|
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||||
|
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
193
apps/coder/src/services/__tests__/write_guard_fuzz.test.ts
Normal file
193
apps/coder/src/services/__tests__/write_guard_fuzz.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
|
||||||
|
const projectRoot = '/opt/testproject';
|
||||||
|
|
||||||
|
describe('write_guard fuzz — traversal attacks', () => {
|
||||||
|
// Basic traversal
|
||||||
|
it('rejects ../', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '../etc/passwd')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ../../', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '../../etc/passwd')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deeply nested ../../../', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '../../../../../../../etc/shadow')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encoded traversal — resolve() doesn't decode percent-encoding, so these
|
||||||
|
// stay as literal filenames. The guard must still not let them escape.
|
||||||
|
it('rejects %2e%2e/ (literal percent-encoded dots)', () => {
|
||||||
|
// resolve('/opt/testproject', '%2e%2e/etc/passwd') stays inside root
|
||||||
|
// because Node's resolve treats the literal characters, not decoded.
|
||||||
|
// The file would be /opt/testproject/%2e%2e/etc/passwd which IS inside root.
|
||||||
|
// This test confirms it doesn't throw (it resolves inside) — defense in depth
|
||||||
|
// is that the filesystem won't have this path, but no traversal occurs.
|
||||||
|
const result = resolveWritePath(projectRoot, '%2e%2e/etc/passwd');
|
||||||
|
expect(result).toContain(projectRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ..%2f (literal percent-encoded slash)', () => {
|
||||||
|
// '../%2fetc/passwd' — the ../ IS real traversal
|
||||||
|
expect(() => resolveWritePath(projectRoot, '../%2fetc/passwd')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Null byte injection
|
||||||
|
it('rejects null bytes', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'file.txt\x00.jpg')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Absolute path escape
|
||||||
|
it('rejects /etc/passwd', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '/etc/passwd')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects /opt/other-project/file', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '/opt/other-project/file.ts')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Path that starts with project root as prefix but isn't under it
|
||||||
|
it('rejects prefix match without separator', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '/opt/testproject-evil/file.ts')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double slashes / traversal after valid prefix
|
||||||
|
it('rejects /opt/testproject/../etc/passwd via double-dot after valid prefix', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '/opt/testproject/../etc/passwd')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Windows-style (defense-in-depth on Linux)
|
||||||
|
it('rejects backslash traversal', () => {
|
||||||
|
// On POSIX, backslash is a valid filename char, so '..\\etc\\passwd' resolves
|
||||||
|
// as a single segment inside projectRoot. Not a traversal, but test that it
|
||||||
|
// doesn't crash and stays within root.
|
||||||
|
const result = resolveWritePath(projectRoot, '..\\etc\\passwd');
|
||||||
|
// Node resolve on POSIX treats this as a literal filename segment containing backslashes
|
||||||
|
// that starts with '..' — resolve normalizes: /opt/testproject/..\\etc\\passwd
|
||||||
|
// Wait: resolve('/opt/testproject', '..\\etc\\passwd') — on POSIX backslash
|
||||||
|
// is NOT a separator, so this is a file named '..\\etc\\passwd' inside projectRoot.
|
||||||
|
// Actually no — resolve splits on '/' only on POSIX. '..' at start triggers parent.
|
||||||
|
// Let's check: the string starts with '..' but the next char is '\\' not '/'.
|
||||||
|
// Node's path.resolve on POSIX: the string '..\\etc\\passwd' does NOT contain '/'
|
||||||
|
// so it IS treated as a single path component? No — resolve still splits on '/'.
|
||||||
|
// '..\\etc\\passwd' has no '/', so resolve('/opt/testproject', '..\\etc\\passwd')
|
||||||
|
// = resolve('/opt/testproject/..\\etc\\passwd') — but wait, resolve processes
|
||||||
|
// segments separated by '/'. With no '/', the whole thing is one segment.
|
||||||
|
// Actually wrong: path.resolve calls normalizeString which handles '.' and '..'
|
||||||
|
// only when they are full segments delimited by '/'. Since there's no '/' in
|
||||||
|
// '..\\etc\\passwd', it treats the entire string as one filename.
|
||||||
|
// So: /opt/testproject/..\\etc\\passwd — inside root. No throw.
|
||||||
|
expect(result).toContain(projectRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secret files (deny list)
|
||||||
|
it('rejects .env', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '.env')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects nested .env', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'config/.env')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects .env.local', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '.env.local')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects id_rsa', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '.ssh/id_rsa')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects id_ed25519', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '.ssh/id_ed25519')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects *.pem', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'certs/server.pem')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects *.key', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'certs/private.key')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects credentials.json', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'credentials.json')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects *.p12', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'certs/client.p12')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects .netrc', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '.netrc')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects *.kdbx', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, 'secrets/passwords.kdbx')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Valid paths (should NOT throw)
|
||||||
|
it('allows simple relative path', () => {
|
||||||
|
expect(resolveWritePath(projectRoot, 'src/index.ts')).toBe('/opt/testproject/src/index.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows nested path', () => {
|
||||||
|
expect(resolveWritePath(projectRoot, 'src/services/tools/edit_file.ts')).toContain(projectRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows dotfile that is not in deny list', () => {
|
||||||
|
expect(resolveWritePath(projectRoot, '.gitignore')).toContain(projectRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows absolute path inside project', () => {
|
||||||
|
expect(resolveWritePath(projectRoot, '/opt/testproject/new-file.ts')).toBe('/opt/testproject/new-file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows path with safe internal ../', () => {
|
||||||
|
expect(resolveWritePath(projectRoot, 'src/../lib/utils.ts')).toBe('/opt/testproject/lib/utils.ts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('write_guard fuzz — edge cases', () => {
|
||||||
|
it('throws on empty string', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, '')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on whitespace-only', () => {
|
||||||
|
expect(() => resolveWritePath(projectRoot, ' ')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when path IS the project root itself', () => {
|
||||||
|
// Writing to the directory itself makes no sense for a file write
|
||||||
|
expect(() => resolveWritePath(projectRoot, '/opt/testproject')).not.toThrow();
|
||||||
|
// The guard allows it (resolve === projectRoot passes the check).
|
||||||
|
// This is acceptable because the filesystem write will fail on a directory.
|
||||||
|
// If we want to block this, that's a separate concern.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles very long path without crashing', () => {
|
||||||
|
const longSegment = 'a'.repeat(255);
|
||||||
|
const longPath = Array(20).fill(longSegment).join('/');
|
||||||
|
// Should not crash — may throw or succeed, but must not buffer-overflow
|
||||||
|
expect(() => resolveWritePath(projectRoot, longPath)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles path with only dots', () => {
|
||||||
|
// Single dot resolves to projectRoot itself
|
||||||
|
const result = resolveWritePath(projectRoot, './src/file.ts');
|
||||||
|
expect(result).toBe('/opt/testproject/src/file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects triple-dot trick (... is not special but ../ within is)', () => {
|
||||||
|
// '.../etc' is a literal directory name, not traversal
|
||||||
|
const result = resolveWritePath(projectRoot, '.../etc');
|
||||||
|
expect(result).toContain(projectRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path with multiple consecutive slashes', () => {
|
||||||
|
// resolve normalizes these; should still be inside root
|
||||||
|
const result = resolveWritePath(projectRoot, 'src///file.ts');
|
||||||
|
expect(result).toBe('/opt/testproject/src/file.ts');
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/coder/src/services/acp-client-fs.ts
Normal file
35
apps/coder/src/services/acp-client-fs.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||||
|
export async function readWorktreeTextFile(
|
||||||
|
worktreePath: string,
|
||||||
|
filePath: string,
|
||||||
|
line?: number | null,
|
||||||
|
limit?: number | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||||
|
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||||
|
throw new Error(`path escapes worktree: ${filePath}`);
|
||||||
|
}
|
||||||
|
const raw = await fs.readFile(absolute, 'utf8');
|
||||||
|
if (!line && !limit) return raw;
|
||||||
|
const lines = raw.split(/\r?\n/);
|
||||||
|
const start = Math.max((line ?? 1) - 1, 0);
|
||||||
|
const end = limit ? start + limit : undefined;
|
||||||
|
return lines.slice(start, end).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a file inside the worktree (creates parent dirs). */
|
||||||
|
export async function writeWorktreeTextFile(
|
||||||
|
worktreePath: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||||
|
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||||
|
throw new Error(`path escapes worktree: ${filePath}`);
|
||||||
|
}
|
||||||
|
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||||
|
await fs.writeFile(absolute, content, 'utf8');
|
||||||
|
}
|
||||||
128
apps/coder/src/services/acp-derive.ts
Normal file
128
apps/coder/src/services/acp-derive.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* ACP model/mode derivation — adapted from Paseo acp-agent.ts.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
SessionConfigOption,
|
||||||
|
SessionModelState,
|
||||||
|
SessionModeState,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
|
import type { ProviderMode, ProviderModel, ThinkingOption } from './provider-types.js';
|
||||||
|
|
||||||
|
type SelectConfigOption = Extract<SessionConfigOption, { type: 'select' }>;
|
||||||
|
|
||||||
|
interface SelectConfigChoice {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSelectConfigOption({
|
||||||
|
configOptions,
|
||||||
|
category,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
configOptions: SessionConfigOption[] | null | undefined;
|
||||||
|
category: string;
|
||||||
|
id?: string;
|
||||||
|
}): SelectConfigOption | null {
|
||||||
|
const option = configOptions?.find(
|
||||||
|
(entry): entry is SelectConfigOption =>
|
||||||
|
entry.type === 'select' && entry.category === category && (!id || entry.id === id),
|
||||||
|
);
|
||||||
|
return option ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSelectOptions(options: SelectConfigOption['options']): SelectConfigChoice[] {
|
||||||
|
const flattened: SelectConfigChoice[] = [];
|
||||||
|
for (const option of options) {
|
||||||
|
if ('value' in option) {
|
||||||
|
flattened.push(option);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const groupOption of option.options) {
|
||||||
|
flattened.push({ ...groupOption, group: option.group });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSelectorOptions(
|
||||||
|
configOptions: SessionConfigOption[] | null | undefined,
|
||||||
|
category: string,
|
||||||
|
): ThinkingOption[] {
|
||||||
|
const option = findSelectConfigOption({ configOptions, category });
|
||||||
|
if (!option) return [];
|
||||||
|
|
||||||
|
return flattenSelectOptions(option.options).map((value) => ({
|
||||||
|
id: value.value,
|
||||||
|
label: value.name,
|
||||||
|
isDefault: value.value === option.currentValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveModesFromACP(
|
||||||
|
fallbackModes: ProviderMode[],
|
||||||
|
modeState?: SessionModeState | null,
|
||||||
|
configOptions?: SessionConfigOption[] | null,
|
||||||
|
): { 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,
|
||||||
|
})),
|
||||||
|
currentModeId: modeState.currentModeId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeOption = findSelectConfigOption({ configOptions, category: 'mode' });
|
||||||
|
if (modeOption) {
|
||||||
|
const flatOptions = flattenSelectOptions(modeOption.options);
|
||||||
|
return {
|
||||||
|
modes: flatOptions.map((option) => ({
|
||||||
|
id: option.value,
|
||||||
|
label: option.name,
|
||||||
|
description: option.description ?? undefined,
|
||||||
|
})),
|
||||||
|
currentModeId: modeOption.currentValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modes: fallbackModes, currentModeId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveModelDefinitionsFromACP(
|
||||||
|
models: SessionModelState | null | undefined,
|
||||||
|
configOptions?: SessionConfigOption[] | null,
|
||||||
|
): ProviderModel[] {
|
||||||
|
const thinkingOptions = deriveSelectorOptions(configOptions, 'thought_level');
|
||||||
|
const defaultThinkingOptionId = thinkingOptions.find((o) => o.isDefault)?.id;
|
||||||
|
|
||||||
|
if (models?.availableModels?.length) {
|
||||||
|
return models.availableModels.map((model) => ({
|
||||||
|
id: model.modelId,
|
||||||
|
label: model.name,
|
||||||
|
description: model.description ?? undefined,
|
||||||
|
isDefault: model.modelId === models.currentModelId,
|
||||||
|
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||||
|
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelOptions = deriveSelectorOptions(configOptions, 'model');
|
||||||
|
return modelOptions.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
label: option.label,
|
||||||
|
isDefault: option.isDefault,
|
||||||
|
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||||
|
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findThoughtLevelConfigId(
|
||||||
|
configOptions: SessionConfigOption[] | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
return findSelectConfigOption({ configOptions, category: 'thought_level' })?.id ?? null;
|
||||||
|
}
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH.
|
* ACP dispatch — runs ACP-capable agents directly on the host.
|
||||||
*
|
*
|
||||||
* Uses the @agentclientprotocol/sdk to establish a structured JSON-RPC session
|
* v2.3: Paseo-aligned tool lifecycle — stable toolCallId, merge on
|
||||||
* with the agent subprocess. The SSH tunnel provides stdio transport.
|
* tool_call_update, reasoning stream, worktree FS client, persist-ready snapshots.
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. SSH to host, start `opencode acp` (or `goose acp`) in the worktree
|
|
||||||
* 2. Wrap SSH child's stdin/stdout into NDJSON streams
|
|
||||||
* 3. Create a ClientSideConnection from the SDK
|
|
||||||
* 4. Initialize → newSession → prompt(task)
|
|
||||||
* 5. Collect session updates (tool calls, text output)
|
|
||||||
* 6. On prompt completion → return collected output
|
|
||||||
*/
|
*/
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import { Readable, Writable } from 'node:stream';
|
|
||||||
import {
|
import {
|
||||||
ClientSideConnection,
|
ClientSideConnection,
|
||||||
ndJsonStream,
|
|
||||||
type Client,
|
type Client,
|
||||||
type SessionNotification,
|
type SessionNotification,
|
||||||
type RequestPermissionRequest,
|
type RequestPermissionRequest,
|
||||||
@@ -27,13 +17,32 @@ import {
|
|||||||
type WriteTextFileResponse,
|
type WriteTextFileResponse,
|
||||||
type CreateTerminalRequest,
|
type CreateTerminalRequest,
|
||||||
type CreateTerminalResponse,
|
type CreateTerminalResponse,
|
||||||
|
type CreateElicitationRequest,
|
||||||
|
type CreateElicitationResponse,
|
||||||
|
type SessionConfigOption,
|
||||||
|
type ClientSideConnection as ConnectionType,
|
||||||
} from '@agentclientprotocol/sdk';
|
} from '@agentclientprotocol/sdk';
|
||||||
import { sshSpawn } from './ssh.js';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||||
|
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||||
|
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||||
|
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||||
|
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
|
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||||
|
import {
|
||||||
|
type AcpToolSnapshot,
|
||||||
|
mergeToolSnapshot,
|
||||||
|
snapshotToWireToolCall,
|
||||||
|
synthesizeCanceledSnapshots,
|
||||||
|
} from './acp-tool-snapshot.js';
|
||||||
|
|
||||||
export interface AcpDispatchResult {
|
export interface AcpDispatchResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
output: string;
|
output: string;
|
||||||
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>;
|
toolSnapshots: AcpToolSnapshot[];
|
||||||
|
reasoningText: string;
|
||||||
stopReason: string;
|
stopReason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,211 +51,322 @@ export interface AcpDispatchOpts {
|
|||||||
task: string;
|
task: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
chatId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
broker?: Broker;
|
||||||
|
installPath?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map agent name to the ACP command it exposes. */
|
async function applySessionOverrides(
|
||||||
function acpCommand(agent: string): string | null {
|
connection: ConnectionType,
|
||||||
switch (agent) {
|
acpSessionId: string,
|
||||||
case 'opencode':
|
configOptions: SessionConfigOption[] | null | undefined,
|
||||||
return 'opencode acp';
|
opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
|
||||||
case 'goose':
|
): Promise<void> {
|
||||||
return 'goose acp';
|
const { model, modeId, thinkingOptionId, log } = opts;
|
||||||
|
|
||||||
|
if (modeId) {
|
||||||
|
try {
|
||||||
|
await connection.setSessionMode({ sessionId: acpSessionId, modeId });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ modeId, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionMode failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
try {
|
||||||
|
await connection.unstable_setSessionModel({ sessionId: acpSessionId, modelId: model });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ model, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionModel failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thinkingOptionId) {
|
||||||
|
const configId = findThoughtLevelConfigId(configOptions);
|
||||||
|
if (configId) {
|
||||||
|
try {
|
||||||
|
await connection.setSessionConfigOption({
|
||||||
|
sessionId: acpSessionId,
|
||||||
|
configId,
|
||||||
|
value: thinkingOptionId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{ thinkingOptionId, err: err instanceof Error ? err.message : String(err) },
|
||||||
|
'acp-dispatch: setSessionConfigOption failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AcpStreamContext {
|
||||||
|
readonly textChunks: string[] = [];
|
||||||
|
readonly reasoningChunks: string[] = [];
|
||||||
|
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
|
||||||
|
private aborted = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly opts: Pick<
|
||||||
|
AcpDispatchOpts,
|
||||||
|
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
|
||||||
|
>,
|
||||||
|
private readonly worktreePath: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get reasoningText(): string {
|
||||||
|
return this.reasoningChunks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
get output(): string {
|
||||||
|
return this.textChunks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
get snapshots(): AcpToolSnapshot[] {
|
||||||
|
return [...this.toolSnapshots.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
markAborted(): void {
|
||||||
|
this.aborted = true;
|
||||||
|
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
|
||||||
|
this.toolSnapshots.set(snap.toolCallId, snap);
|
||||||
|
this.publishToolSnapshot(snap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private canStream(): boolean {
|
||||||
|
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
|
||||||
|
if (!this.canStream()) return;
|
||||||
|
const wire = snapshotToWireToolCall(snapshot);
|
||||||
|
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||||
|
type: 'tool_call',
|
||||||
|
message_id: this.opts.messageId!,
|
||||||
|
chat_id: this.opts.chatId!,
|
||||||
|
tool_call: wire,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
|
||||||
|
const previous = this.toolSnapshots.get(toolCallId);
|
||||||
|
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
|
||||||
|
this.toolSnapshots.set(toolCallId, snapshot);
|
||||||
|
this.publishToolSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSessionUpdate(params: SessionNotification): Promise<void> {
|
||||||
|
const update = params.update;
|
||||||
|
switch (update.sessionUpdate) {
|
||||||
|
case 'agent_message_chunk': {
|
||||||
|
const content = update.content;
|
||||||
|
if (content.type === 'text' && 'text' in content) {
|
||||||
|
const text = (content as { text: string }).text;
|
||||||
|
this.textChunks.push(text);
|
||||||
|
if (this.canStream()) {
|
||||||
|
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: this.opts.messageId!,
|
||||||
|
chat_id: this.opts.chatId!,
|
||||||
|
content: text,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'agent_thought_chunk': {
|
||||||
|
const content = update.content;
|
||||||
|
if (content.type === 'text' && 'text' in content) {
|
||||||
|
const text = (content as { text: string }).text;
|
||||||
|
this.reasoningChunks.push(text);
|
||||||
|
if (this.canStream()) {
|
||||||
|
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||||
|
type: 'reasoning_delta',
|
||||||
|
message_id: this.opts.messageId!,
|
||||||
|
chat_id: this.opts.chatId!,
|
||||||
|
content: text,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tool_call':
|
||||||
|
this.handleToolUpdate(update.toolCallId, update);
|
||||||
|
break;
|
||||||
|
case 'tool_call_update':
|
||||||
|
this.handleToolUpdate(update.toolCallId, update);
|
||||||
|
break;
|
||||||
|
case 'available_commands_update': {
|
||||||
|
const commands = update.availableCommands.map((cmd) => ({
|
||||||
|
name: cmd.name,
|
||||||
|
description: cmd.description ?? undefined,
|
||||||
|
}));
|
||||||
|
if (this.opts.taskId && commands.length > 0) {
|
||||||
|
mergeTaskCommands(this.opts.taskId, commands);
|
||||||
|
if (this.canStream() && this.opts.sessionId) {
|
||||||
|
const all = getTaskCommands(this.opts.taskId) ?? commands;
|
||||||
|
this.opts.broker!.publishFrame(this.opts.sessionId, {
|
||||||
|
type: 'agent_commands',
|
||||||
|
task_id: this.opts.taskId,
|
||||||
|
session_id: this.opts.sessionId,
|
||||||
|
commands: all,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
|
||||||
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>.
|
return {
|
||||||
*/
|
sessionUpdate: (params) => this.handleSessionUpdate(params),
|
||||||
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
||||||
return new ReadableStream<Uint8Array>({
|
if (taskId && sessionId) {
|
||||||
start(controller) {
|
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
|
||||||
nodeStream.on('data', (chunk: Buffer) => {
|
|
||||||
controller.enqueue(new Uint8Array(chunk));
|
|
||||||
});
|
|
||||||
nodeStream.on('end', () => {
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
nodeStream.on('error', (err) => {
|
|
||||||
controller.error(err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
|
||||||
(nodeStream as Readable).destroy();
|
|
||||||
}
|
}
|
||||||
|
const firstOption = params.options[0];
|
||||||
|
if (firstOption) {
|
||||||
|
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
|
||||||
|
}
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
},
|
},
|
||||||
});
|
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
|
||||||
|
const content = await readWorktreeTextFile(
|
||||||
|
this.worktreePath,
|
||||||
|
params.path,
|
||||||
|
params.line,
|
||||||
|
params.limit,
|
||||||
|
);
|
||||||
|
return { content };
|
||||||
|
},
|
||||||
|
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
|
||||||
|
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||||
|
return { terminalId: 'noop' };
|
||||||
|
},
|
||||||
|
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
|
||||||
|
if (taskId && sessionId) {
|
||||||
|
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
|
||||||
|
}
|
||||||
|
return { action: 'decline' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
|
|
||||||
*/
|
|
||||||
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
|
||||||
return new WritableStream<Uint8Array>({
|
|
||||||
write(chunk) {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
});
|
|
||||||
if (ok) resolve();
|
|
||||||
else (nodeStream as Writable).once('drain', resolve);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
(nodeStream as Writable).end(resolve);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
abort() {
|
|
||||||
(nodeStream as Writable).destroy();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch a task to an ACP-capable agent via SSH.
|
|
||||||
*
|
|
||||||
* Opens a structured ACP session, sends the task as a prompt, and collects
|
|
||||||
* all session updates. Returns the collected output and tool calls.
|
|
||||||
*/
|
|
||||||
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
|
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
|
||||||
const { agent, task, worktreePath, signal, log } = opts;
|
const {
|
||||||
|
agent,
|
||||||
|
task,
|
||||||
|
worktreePath,
|
||||||
|
installPath,
|
||||||
|
signal,
|
||||||
|
log,
|
||||||
|
taskId,
|
||||||
|
modeId,
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
broker,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
const cmd = acpCommand(agent);
|
const args = resolveAcpSpawnArgs(agent);
|
||||||
if (!cmd) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
output: `Agent '${agent}' does not support ACP.`,
|
output: `Agent '${agent}' does not support ACP.`,
|
||||||
toolCalls: [],
|
toolSnapshots: [],
|
||||||
|
reasoningText: '',
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn SSH with the ACP command running in the worktree
|
const binary = installPath ?? agent;
|
||||||
const escapedPath = worktreePath.replace(/'/g, "'\\''");
|
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||||
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
|
const child = spawn(binary, args, {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
|
const streamCtx = new AcpStreamContext(
|
||||||
const child = sshSpawn(fullCommand);
|
{ broker, sessionId, chatId, messageId, taskId },
|
||||||
|
worktreePath,
|
||||||
|
);
|
||||||
|
|
||||||
// Wire up abort
|
|
||||||
let killed = false;
|
let killed = false;
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!killed) {
|
if (!killed) {
|
||||||
killed = true;
|
killed = true;
|
||||||
|
streamCtx.markAborted();
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||||
}
|
}
|
||||||
|
if (taskId) cancelPendingPermission(taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
cleanup();
|
cleanup();
|
||||||
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], stopReason: 'cancelled' };
|
return {
|
||||||
|
exitCode: 130,
|
||||||
|
output: 'Aborted before start',
|
||||||
|
toolSnapshots: streamCtx.snapshots,
|
||||||
|
reasoningText: '',
|
||||||
|
stopReason: 'cancelled',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
signal.addEventListener('abort', cleanup, { once: true });
|
signal.addEventListener('abort', cleanup, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create web streams from the child process stdio
|
const stream = createAcpNdJsonStream(child);
|
||||||
const inputStream = nodeReadableToWeb(child.stdout!);
|
|
||||||
const outputStream = nodeWritableToWeb(child.stdin!);
|
|
||||||
|
|
||||||
// Create the NDJSON ACP stream
|
|
||||||
const stream = ndJsonStream(outputStream, inputStream);
|
|
||||||
|
|
||||||
// Collected session updates
|
|
||||||
const textChunks: string[] = [];
|
|
||||||
const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = [];
|
|
||||||
|
|
||||||
// Create client-side connection — we are the "client" (editor), the agent is remote
|
|
||||||
const connection = new ClientSideConnection(
|
const connection = new ClientSideConnection(
|
||||||
(_agentInterface): Client => ({
|
() => streamCtx.buildClient(agent, modeId, taskId, sessionId),
|
||||||
// Handle session updates from the agent
|
|
||||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
|
||||||
const update = params.update;
|
|
||||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
||||||
// ContentChunk with content: ContentBlock
|
|
||||||
const content = update.content;
|
|
||||||
if (content.type === 'text' && 'text' in content) {
|
|
||||||
textChunks.push((content as { text: string }).text);
|
|
||||||
}
|
|
||||||
} else if (update.sessionUpdate === 'tool_call') {
|
|
||||||
toolCalls.push({
|
|
||||||
title: update.title,
|
|
||||||
input: update.rawInput,
|
|
||||||
});
|
|
||||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
|
||||||
const last = toolCalls[toolCalls.length - 1];
|
|
||||||
if (last && update.rawOutput !== undefined) {
|
|
||||||
last.output = update.rawOutput;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
|
|
||||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
|
||||||
// Select the first available option to auto-approve
|
|
||||||
const firstOption = params.options[0];
|
|
||||||
if (firstOption) {
|
|
||||||
return {
|
|
||||||
outcome: { outcome: 'selected', optionId: firstOption.optionId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// No options available — cancel
|
|
||||||
return { outcome: { outcome: 'cancelled' } };
|
|
||||||
},
|
|
||||||
|
|
||||||
// File system operations — let the agent handle them directly in the worktree
|
|
||||||
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
|
||||||
return { content: '' };
|
|
||||||
},
|
|
||||||
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
|
||||||
return { terminalId: 'noop' };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
stream,
|
stream,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize the connection
|
await connection.initialize({
|
||||||
// ProtocolVersion is a number in this SDK version
|
|
||||||
const initResult = await connection.initialize({
|
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
clientInfo: { name: 'boocoder', version: '2.0.1' },
|
clientInfo: { name: 'boocoder', version: '2.3.0' },
|
||||||
clientCapabilities: {},
|
clientCapabilities: {},
|
||||||
});
|
});
|
||||||
log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized');
|
|
||||||
|
|
||||||
// Create a new session
|
const acpSession = await connection.newSession({ cwd: worktreePath, mcpServers: [] });
|
||||||
const session = await connection.newSession({
|
log.info({ sessionId: acpSession.sessionId }, 'acp-dispatch: session created');
|
||||||
cwd: worktreePath,
|
|
||||||
mcpServers: [],
|
await applySessionOverrides(connection, acpSession.sessionId, acpSession.configOptions, opts);
|
||||||
});
|
|
||||||
log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created');
|
|
||||||
|
|
||||||
// Send the prompt
|
|
||||||
const promptResult = await connection.prompt({
|
const promptResult = await connection.prompt({
|
||||||
sessionId: session.sessionId,
|
sessionId: acpSession.sessionId,
|
||||||
prompt: [{ type: 'text', text: task }],
|
prompt: [{ type: 'text', text: task }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopReason = promptResult.stopReason ?? 'end_turn';
|
const stopReason = promptResult.stopReason ?? 'end_turn';
|
||||||
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed');
|
log.info(
|
||||||
|
{ agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length },
|
||||||
|
'acp-dispatch: prompt completed',
|
||||||
|
);
|
||||||
|
|
||||||
// Clean shutdown
|
await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {});
|
||||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
output: textChunks.join(''),
|
output: streamCtx.output,
|
||||||
toolCalls,
|
toolSnapshots: streamCtx.snapshots,
|
||||||
|
reasoningText: streamCtx.reasoningText,
|
||||||
stopReason,
|
stopReason,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -255,14 +375,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
|||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
output: message,
|
output: message,
|
||||||
toolCalls: [],
|
toolSnapshots: streamCtx.snapshots,
|
||||||
|
reasoningText: streamCtx.reasoningText,
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (signal) signal.removeEventListener('abort', cleanup);
|
if (signal) signal.removeEventListener('abort', cleanup);
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
// Wait for child to exit
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
child.on('close', resolve);
|
child.on('close', resolve);
|
||||||
setTimeout(resolve, 3_000);
|
setTimeout(resolve, 3_000);
|
||||||
|
|||||||
155
apps/coder/src/services/acp-probe.ts
Normal file
155
apps/coder/src/services/acp-probe.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Short-lived ACP probe — opens a session and reads models/modes from the response.
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
ClientSideConnection,
|
||||||
|
type Client,
|
||||||
|
type NewSessionResponse,
|
||||||
|
type ReadTextFileRequest,
|
||||||
|
type ReadTextFileResponse,
|
||||||
|
type WriteTextFileRequest,
|
||||||
|
type WriteTextFileResponse,
|
||||||
|
type CreateTerminalRequest,
|
||||||
|
type CreateTerminalResponse,
|
||||||
|
type RequestPermissionRequest,
|
||||||
|
type RequestPermissionResponse,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
|
import { deriveModesFromACP, deriveModelDefinitionsFromACP } from './acp-derive.js';
|
||||||
|
import { getManifestDefaultModeId, getManifestModes } from './provider-manifest.js';
|
||||||
|
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||||
|
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||||
|
import type { ProviderModel, ProviderMode } from './provider-types.js';
|
||||||
|
import type { AgentCommand } from './agent-commands-cache.js';
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export interface AcpProbeResult {
|
||||||
|
ok: boolean;
|
||||||
|
models: ProviderModel[];
|
||||||
|
modes: ProviderMode[];
|
||||||
|
defaultModeId: string | null;
|
||||||
|
commands: AgentCommand[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSessionResponse(session: NewSessionResponse, agent: string): AcpProbeResult {
|
||||||
|
const fallbackModes = getManifestModes(agent);
|
||||||
|
const { modes, currentModeId } = deriveModesFromACP(
|
||||||
|
fallbackModes,
|
||||||
|
session.modes,
|
||||||
|
session.configOptions,
|
||||||
|
);
|
||||||
|
const models = deriveModelDefinitionsFromACP(session.models, session.configOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
models,
|
||||||
|
modes,
|
||||||
|
defaultModeId: currentModeId ?? getManifestDefaultModeId(agent),
|
||||||
|
commands: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeAcpProvider(
|
||||||
|
agent: string,
|
||||||
|
installPath: string,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<AcpProbeResult> {
|
||||||
|
const args = resolveAcpSpawnArgs(agent);
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
models: [],
|
||||||
|
modes: getManifestModes(agent),
|
||||||
|
defaultModeId: getManifestDefaultModeId(agent),
|
||||||
|
commands: [],
|
||||||
|
error: 'no ACP spawn args',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(installPath, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
|
let killed = false;
|
||||||
|
const kill = () => {
|
||||||
|
if (!killed) {
|
||||||
|
killed = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => child.kill('SIGKILL'), 2_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(kill, PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const probedCommands: AgentCommand[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = createAcpNdJsonStream(child);
|
||||||
|
|
||||||
|
const connection = new ClientSideConnection(
|
||||||
|
(_agentInterface): Client => ({
|
||||||
|
async sessionUpdate(params) {
|
||||||
|
const update = params.update;
|
||||||
|
if (update.sessionUpdate === 'available_commands_update') {
|
||||||
|
for (const cmd of update.availableCommands) {
|
||||||
|
probedCommands.push({
|
||||||
|
name: cmd.name,
|
||||||
|
description: cmd.description ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||||
|
const first = params.options[0];
|
||||||
|
if (first) {
|
||||||
|
return { outcome: { outcome: 'selected', optionId: first.optionId } };
|
||||||
|
}
|
||||||
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
|
},
|
||||||
|
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||||
|
return { content: '' };
|
||||||
|
},
|
||||||
|
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||||
|
return { terminalId: 'noop' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientInfo: { name: 'boocoder-probe', version: '2.2.0' },
|
||||||
|
clientCapabilities: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||||
|
const result = parseSessionResponse(session, agent);
|
||||||
|
result.commands = probedCommands;
|
||||||
|
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
models: [],
|
||||||
|
modes: getManifestModes(agent),
|
||||||
|
defaultModeId: getManifestDefaultModeId(agent),
|
||||||
|
commands: probedCommands,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
kill();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
child.on('close', resolve);
|
||||||
|
setTimeout(resolve, 2_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/coder/src/services/acp-spawn.ts
Normal file
18
apps/coder/src/services/acp-spawn.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
|
||||||
|
*/
|
||||||
|
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||||
|
switch (agent) {
|
||||||
|
case 'opencode':
|
||||||
|
case 'goose':
|
||||||
|
return ['acp'];
|
||||||
|
case 'qwen':
|
||||||
|
return ['--acp'];
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||||
|
return [agent];
|
||||||
|
}
|
||||||
44
apps/coder/src/services/acp-stream.ts
Normal file
44
apps/coder/src/services/acp-stream.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Readable, Writable } from 'node:stream';
|
||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
import { ndJsonStream } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
|
export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
||||||
|
nodeStream.on('end', () => controller.close());
|
||||||
|
nodeStream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
||||||
|
(nodeStream as Readable).destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
||||||
|
return new WritableStream<Uint8Array>({
|
||||||
|
write(chunk) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
});
|
||||||
|
if (ok) resolve();
|
||||||
|
else (nodeStream as Writable).once('drain', resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
(nodeStream as Writable).end(resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
abort() {
|
||||||
|
(nodeStream as Writable).destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAcpNdJsonStream(child: ChildProcess) {
|
||||||
|
return ndJsonStream(nodeWritableToWeb(child.stdin!), nodeReadableToWeb(child.stdout!));
|
||||||
|
}
|
||||||
120
apps/coder/src/services/acp-tool-snapshot.ts
Normal file
120
apps/coder/src/services/acp-tool-snapshot.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* ACP tool snapshot merge + wire mapping — lifted from Paseo acp-agent.ts patterns.
|
||||||
|
* Stable toolCallId, merge on tool_call_update, status lifecycle for UI + DB.
|
||||||
|
*/
|
||||||
|
import type { ToolCall, ToolCallUpdate, ToolCallStatus, ToolKind } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
|
export type AcpToolLifecycleStatus = 'running' | 'completed' | 'failed' | 'canceled';
|
||||||
|
|
||||||
|
export interface AcpToolSnapshot {
|
||||||
|
toolCallId: string;
|
||||||
|
title: string;
|
||||||
|
kind?: ToolKind | null;
|
||||||
|
status?: ToolCallStatus | null;
|
||||||
|
rawInput?: unknown;
|
||||||
|
rawOutput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpWireMeta {
|
||||||
|
status: AcpToolLifecycleStatus;
|
||||||
|
kind?: string | null;
|
||||||
|
title?: string;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coalesceDefined<T>(next: T | null | undefined, previous: T | null | undefined, fallback: T | null): T | null {
|
||||||
|
if (next !== undefined && next !== null) return next;
|
||||||
|
if (previous !== undefined && previous !== null) return previous;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeToolSnapshot(
|
||||||
|
toolCallId: string,
|
||||||
|
update: ToolCall | ToolCallUpdate,
|
||||||
|
previous?: AcpToolSnapshot,
|
||||||
|
): AcpToolSnapshot {
|
||||||
|
return {
|
||||||
|
toolCallId,
|
||||||
|
title: update.title ?? previous?.title ?? toolCallId,
|
||||||
|
kind: update.kind ?? previous?.kind ?? null,
|
||||||
|
status: update.status ?? previous?.status ?? null,
|
||||||
|
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
|
||||||
|
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapToolLifecycleStatus(
|
||||||
|
status: ToolCallStatus | null | undefined,
|
||||||
|
rawOutput?: unknown,
|
||||||
|
): AcpToolLifecycleStatus {
|
||||||
|
if (rawOutput === 'canceled') return 'canceled';
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'completed';
|
||||||
|
case 'failed':
|
||||||
|
return 'failed';
|
||||||
|
case 'pending':
|
||||||
|
case 'in_progress':
|
||||||
|
default:
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorMessage(rawOutput: unknown): string | undefined {
|
||||||
|
if (typeof rawOutput === 'string' && rawOutput.trim()) return rawOutput;
|
||||||
|
if (rawOutput && typeof rawOutput === 'object' && !Array.isArray(rawOutput)) {
|
||||||
|
const rec = rawOutput as Record<string, unknown>;
|
||||||
|
const msg = rec.message ?? rec.error ?? rec.reason;
|
||||||
|
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotToWireToolCall(snapshot: AcpToolSnapshot): {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const lifecycle = mapToolLifecycleStatus(snapshot.status, snapshot.rawOutput);
|
||||||
|
const input = asRecord(snapshot.rawInput);
|
||||||
|
const error = lifecycle === 'failed' ? readErrorMessage(snapshot.rawOutput) : undefined;
|
||||||
|
const meta: AcpWireMeta = {
|
||||||
|
status: lifecycle,
|
||||||
|
kind: snapshot.kind ?? null,
|
||||||
|
title: snapshot.title,
|
||||||
|
...(snapshot.rawOutput !== undefined ? { output: snapshot.rawOutput } : {}),
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: snapshot.toolCallId,
|
||||||
|
name: String(snapshot.kind ?? snapshot.title),
|
||||||
|
args: { ...input, _acp: meta },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotToPartPayload(snapshot: AcpToolSnapshot): {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const wire = snapshotToWireToolCall(snapshot);
|
||||||
|
return { id: wire.id, name: wire.name, args: wire.args };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synthesizeCanceledSnapshots(snapshots: Iterable<AcpToolSnapshot>): AcpToolSnapshot[] {
|
||||||
|
const out: AcpToolSnapshot[] = [];
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
if (mapToolLifecycleStatus(snapshot.status) === 'running') {
|
||||||
|
out.push({ ...snapshot, status: 'failed', rawOutput: snapshot.rawOutput ?? 'canceled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
28
apps/coder/src/services/agent-commands-cache.ts
Normal file
28
apps/coder/src/services/agent-commands-cache.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** In-memory cache of ACP available_commands_update per task. */
|
||||||
|
|
||||||
|
import type { AgentCommand } from './provider-types.js';
|
||||||
|
import { mergeCommands } from './provider-commands.js';
|
||||||
|
|
||||||
|
export type { AgentCommand };
|
||||||
|
|
||||||
|
const commandsByTask = new Map<string, AgentCommand[]>();
|
||||||
|
|
||||||
|
export function setTaskCommands(taskId: string, commands: AgentCommand[]): void {
|
||||||
|
if (commands.length === 0) return;
|
||||||
|
commandsByTask.set(taskId, commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge by command name; later lists override earlier entries. */
|
||||||
|
export function mergeTaskCommands(taskId: string, commands: AgentCommand[]): void {
|
||||||
|
if (commands.length === 0) return;
|
||||||
|
const merged = mergeCommands(commandsByTask.get(taskId) ?? [], commands);
|
||||||
|
commandsByTask.set(taskId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskCommands(taskId: string): AgentCommand[] | null {
|
||||||
|
return commandsByTask.get(taskId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTaskCommands(taskId: string): void {
|
||||||
|
commandsByTask.delete(taskId);
|
||||||
|
}
|
||||||
@@ -1,68 +1,104 @@
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import { sshExec } from './ssh.js';
|
import { exec as execCb } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
|
||||||
|
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||||
|
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||||
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
|
|
||||||
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [
|
const exec = promisify(execCb);
|
||||||
{ name: 'opencode', supportsAcp: true },
|
|
||||||
{ name: 'goose', supportsAcp: true },
|
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
||||||
{ name: 'claude', supportsAcp: false },
|
const candidates = resolveAcpProbeBinaries(agentName);
|
||||||
{ name: 'pi', supportsAcp: false },
|
for (const bin of candidates) {
|
||||||
];
|
try {
|
||||||
|
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
|
||||||
|
const path = stdout.trim();
|
||||||
|
if (path) return path;
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectAcpSupport(agentName: string, installPath: string): Promise<boolean> {
|
||||||
|
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
||||||
|
if (transport !== 'acp') return false;
|
||||||
|
|
||||||
|
if (agentName === 'qwen') {
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||||
|
return stdout.includes('--acp');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exec(`"${installPath}" acp --help`, { timeout: 10_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe for available agents on the HOST via SSH.
|
* Probe for available agents on the HOST.
|
||||||
*
|
|
||||||
* The boocoder container can't run agents locally — they live on the host.
|
|
||||||
* We SSH to the host (same mechanism BooTerm uses) and check which agent
|
|
||||||
* binaries are on PATH.
|
|
||||||
*/
|
*/
|
||||||
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||||
log.info('agent-probe: scanning HOST for known agents via SSH');
|
clearProviderSnapshotCache();
|
||||||
|
log.info('agent-probe: scanning for known agents');
|
||||||
|
|
||||||
for (const agent of KNOWN_AGENTS) {
|
for (const agentName of PROBED_AGENT_NAMES) {
|
||||||
try {
|
try {
|
||||||
// Check if the agent binary is on the host's PATH
|
const installPath = await resolveInstallPath(agentName);
|
||||||
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 });
|
if (!installPath) continue;
|
||||||
const installPath = whichResult.stdout.trim();
|
|
||||||
if (whichResult.exitCode !== 0 || !installPath) continue;
|
|
||||||
|
|
||||||
// Get version
|
|
||||||
let version: string | null = null;
|
let version: string | null = null;
|
||||||
try {
|
try {
|
||||||
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 });
|
const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
|
||||||
if (verResult.exitCode === 0) {
|
version = verOut.trim().slice(0, 100);
|
||||||
version = verResult.stdout.trim().slice(0, 100);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Some agents may not support --version — that's fine
|
/* optional */
|
||||||
}
|
}
|
||||||
|
|
||||||
// For ACP-capable agents, verify ACP mode actually works
|
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||||
let supportsAcp = agent.supportsAcp;
|
let supportsAcp = providerDef?.transport === 'acp';
|
||||||
if (supportsAcp) {
|
if (supportsAcp) {
|
||||||
try {
|
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||||
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
|
|
||||||
supportsAcp = acpCheck.exitCode === 0;
|
|
||||||
} catch {
|
|
||||||
supportsAcp = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPSERT into available_agents
|
let models: Array<{ id: string; label: string }> = [];
|
||||||
|
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||||
|
models = providerDef.staticModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName === 'qwen') {
|
||||||
|
models = await readQwenSettingsModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = providerDef?.label ?? agentName;
|
||||||
|
const transport =
|
||||||
|
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
|
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||||
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
|
VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
|
||||||
ON CONFLICT (name) DO UPDATE SET
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
install_path = EXCLUDED.install_path,
|
install_path = EXCLUDED.install_path,
|
||||||
version = EXCLUDED.version,
|
version = EXCLUDED.version,
|
||||||
supports_acp = EXCLUDED.supports_acp,
|
supports_acp = EXCLUDED.supports_acp,
|
||||||
last_probed_at = EXCLUDED.last_probed_at
|
last_probed_at = EXCLUDED.last_probed_at,
|
||||||
|
models = EXCLUDED.models,
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
transport = EXCLUDED.transport
|
||||||
`;
|
`;
|
||||||
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host');
|
log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// SSH failed or agent not found — skip silently
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found or SSH failed');
|
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
apps/coder/src/services/agent-turn-persist.ts
Normal file
56
apps/coder/src/services/agent-turn-persist.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||||
|
import { snapshotToPartPayload } from './acp-tool-snapshot.js';
|
||||||
|
|
||||||
|
interface PartInsert {
|
||||||
|
message_id: string;
|
||||||
|
sequence: number;
|
||||||
|
kind: 'reasoning' | 'tool_call';
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO message_parts ${sql(
|
||||||
|
parts.map((p) => ({
|
||||||
|
message_id: p.message_id,
|
||||||
|
sequence: p.sequence,
|
||||||
|
kind: p.kind,
|
||||||
|
payload: sql.json(p.payload as never),
|
||||||
|
})),
|
||||||
|
'message_id',
|
||||||
|
'sequence',
|
||||||
|
'kind',
|
||||||
|
'payload',
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist external-agent reasoning + tool calls into message_parts for reload. */
|
||||||
|
export async function persistExternalAgentTurn(
|
||||||
|
sql: Sql,
|
||||||
|
assistantMessageId: string,
|
||||||
|
snapshots: AcpToolSnapshot[],
|
||||||
|
reasoningText: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const parts: PartInsert[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
if (reasoningText.trim()) {
|
||||||
|
parts.push({
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
sequence: seq++,
|
||||||
|
kind: 'reasoning',
|
||||||
|
payload: { text: reasoningText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
parts.push({
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
sequence: seq++,
|
||||||
|
kind: 'tool_call',
|
||||||
|
payload: snapshotToPartPayload(snapshot),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await insertParts(sql, parts);
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
|
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||||
|
import { getManifestCommands } from './provider-commands.js';
|
||||||
|
import { persistExternalAgentTurn } from './agent-turn-persist.js';
|
||||||
|
|
||||||
interface InferenceRunner {
|
interface InferenceRunner {
|
||||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
@@ -20,22 +24,44 @@ interface Deps {
|
|||||||
config: Config;
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
|
||||||
|
// tasks immediately. The poll is only a safety net for notifications missed
|
||||||
|
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
const COMPLETION_POLL_MS = 2_000;
|
const COMPLETION_POLL_MS = 2_000;
|
||||||
|
|
||||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||||
const { sql, inference, log, config } = deps;
|
const { sql, inference, broker, log, config } = deps;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||||
let running = false;
|
let running = false;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let inflightPromise: Promise<void> | null = null;
|
let inflightPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||||
|
// `running`/`stopping` guard makes this safe to call concurrently — a notify
|
||||||
|
// arriving mid-task returns immediately and never double-dispatches.
|
||||||
|
function triggerPoll(reason: string): void {
|
||||||
|
poll().catch((err) => {
|
||||||
|
log.error({ err, reason }, 'dispatcher: poll error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function poll(): Promise<void> {
|
async function poll(): Promise<void> {
|
||||||
if (running || stopping) return;
|
if (running || stopping) return;
|
||||||
|
|
||||||
// Grab one pending task
|
// Grab one pending task
|
||||||
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
|
const rows = await sql<{
|
||||||
SELECT id, project_id, input, agent, model
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
input: string;
|
||||||
|
agent: string | null;
|
||||||
|
model: string | null;
|
||||||
|
mode_id: string | null;
|
||||||
|
thinking_option_id: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
}[]>`
|
||||||
|
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE state = 'pending'
|
WHERE state = 'pending'
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
@@ -51,16 +77,25 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
|
async function runTask(task: {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
input: string;
|
||||||
|
agent: string | null;
|
||||||
|
model: string | null;
|
||||||
|
mode_id: string | null;
|
||||||
|
thinking_option_id: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
|
|
||||||
// Determine execution path: if agent is specified AND exists in available_agents → Path B
|
// Determine execution path: if agent is specified AND exists in available_agents → Path B
|
||||||
if (task.agent) {
|
if (task.agent) {
|
||||||
const [agentRow] = await sql<{ name: string; supports_acp: boolean }[]>`
|
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
|
||||||
SELECT name, supports_acp FROM available_agents WHERE name = ${task.agent}
|
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||||
`;
|
`;
|
||||||
if (agentRow) {
|
if (agentRow) {
|
||||||
await runExternalAgent(task, agentRow.supports_acp);
|
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Agent specified but not available — fall through to Path A with a warning
|
// Agent specified but not available — fall through to Path A with a warning
|
||||||
@@ -73,7 +108,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
|
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||||
|
|
||||||
@@ -179,8 +214,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
|
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||||||
|
|
||||||
async function runExternalAgent(
|
async function runExternalAgent(
|
||||||
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null },
|
task: {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
input: string;
|
||||||
|
agent: string | null;
|
||||||
|
model: string | null;
|
||||||
|
mode_id: string | null;
|
||||||
|
thinking_option_id: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
},
|
||||||
supportsAcp: boolean,
|
supportsAcp: boolean,
|
||||||
|
installPath: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
const agent = task.agent!;
|
const agent = task.agent!;
|
||||||
@@ -189,14 +234,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
|
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
|
||||||
|
|
||||||
// Resolve the project's root path
|
// Resolve the project's root path
|
||||||
const [project] = await sql<{ root_path: string | null }[]>`
|
const [project] = await sql<{ path: string | null }[]>`
|
||||||
SELECT root_path FROM projects WHERE id = ${task.project_id}
|
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||||
`;
|
`;
|
||||||
const projectPath = project?.root_path;
|
const projectPath = project?.path;
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_path — cannot create worktree'
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -213,30 +258,49 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Create session + chat for this task (same as Path A — for output tracking)
|
let sessionId: string;
|
||||||
|
let chatId: string;
|
||||||
|
|
||||||
|
if (task.session_id) {
|
||||||
|
sessionId = task.session_id;
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||||
|
`;
|
||||||
|
if (chats.length === 0) {
|
||||||
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
chatId = chat!.id;
|
||||||
|
} else {
|
||||||
|
chatId = chats[0]!.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||||
const [session] = await sql<{ id: string }[]>`
|
const [session] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO sessions (project_id, name, model, status)
|
INSERT INTO sessions (project_id, name, model, status)
|
||||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const sessionId = session!.id;
|
sessionId = session!.id;
|
||||||
|
|
||||||
const [chat] = await sql<{ id: string }[]>`
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO chats (session_id, name, status)
|
INSERT INTO chats (session_id, name, status)
|
||||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const chatId = chat!.id;
|
chatId = chat!.id;
|
||||||
|
|
||||||
// Link task to session
|
|
||||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Create user message for the task input
|
if (!task.session_id) {
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Create worktree
|
// Step 1: Create worktree
|
||||||
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
|
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
|
||||||
@@ -245,41 +309,91 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
// Step 2: Dispatch to agent
|
// Step 2: Dispatch to agent
|
||||||
let outputSummary: string;
|
let outputSummary: string;
|
||||||
|
let assistantContent = '';
|
||||||
|
let acpReasoning = '';
|
||||||
|
|
||||||
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
} as WsFrame);
|
||||||
|
|
||||||
|
const manifestCommands = getManifestCommands(agent);
|
||||||
|
if (manifestCommands.length > 0) {
|
||||||
|
setTaskCommands(taskId, manifestCommands);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'agent_commands',
|
||||||
|
task_id: taskId,
|
||||||
|
session_id: sessionId,
|
||||||
|
commands: manifestCommands,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
if (supportsAcp) {
|
if (supportsAcp) {
|
||||||
const result = await dispatchViaAcp({
|
const result = await dispatchViaAcp({
|
||||||
agent,
|
agent,
|
||||||
task: task.input,
|
task: task.input,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
installPath: installPath ?? undefined,
|
||||||
model: task.model ?? undefined,
|
model: task.model ?? undefined,
|
||||||
|
modeId: task.mode_id ?? undefined,
|
||||||
|
thinkingOptionId: task.thinking_option_id ?? undefined,
|
||||||
|
taskId,
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
messageId: assistantId,
|
||||||
|
broker,
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
assistantContent = result.output.slice(0, 50_000);
|
||||||
|
acpReasoning = result.reasoningText.slice(0, 200_000);
|
||||||
outputSummary = result.output.slice(0, 500);
|
outputSummary = result.output.slice(0, 500);
|
||||||
|
await persistExternalAgentTurn(sql, assistantId, result.toolSnapshots, acpReasoning);
|
||||||
// Store agent output as an assistant message
|
|
||||||
await sql`
|
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${result.output.slice(0, 50_000)}, 'complete', clock_timestamp())
|
|
||||||
`;
|
|
||||||
} else {
|
} else {
|
||||||
const result = await dispatchViaPty({
|
const result = await dispatchViaPty({
|
||||||
agent,
|
agent,
|
||||||
task: task.input,
|
task: task.input,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
installPath: installPath ?? undefined,
|
||||||
model: task.model ?? undefined,
|
model: task.model ?? undefined,
|
||||||
|
modeId: task.mode_id ?? undefined,
|
||||||
|
thinkingOptionId: task.thinking_option_id ?? undefined,
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
assistantContent = (result.stdout || result.stderr || '(no output)').slice(0, 50_000);
|
||||||
outputSummary = (result.stdout || result.stderr).slice(0, 500);
|
outputSummary = (result.stdout || result.stderr).slice(0, 500);
|
||||||
|
|
||||||
// Store agent output as an assistant message
|
if (assistantContent) {
|
||||||
const content = result.stdout || result.stderr || '(no output)';
|
broker.publishFrame(sessionId, {
|
||||||
await sql`
|
type: 'delta',
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
message_id: assistantId,
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${content.slice(0, 50_000)}, 'complete', clock_timestamp())
|
chat_id: chatId,
|
||||||
`;
|
content: assistantContent,
|
||||||
|
} as WsFrame);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
} as WsFrame);
|
||||||
|
|
||||||
if (stopping) {
|
if (stopping) {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -322,6 +436,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||||
|
clearTaskCommands(taskId);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -335,6 +450,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
// Best-effort cleanup
|
// Best-effort cleanup
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
|
clearTaskCommands(taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,12 +476,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
start() {
|
start() {
|
||||||
log.info('dispatcher: starting poll loop');
|
log.info('dispatcher: starting poll loop + tasks_new listener');
|
||||||
timer = setInterval(() => {
|
|
||||||
poll().catch((err) => {
|
// Fallback poll — catches notifications missed while the listen connection
|
||||||
log.error({ err }, 'dispatcher: poll error');
|
// was down. The fast path is the NOTIFY listener below.
|
||||||
|
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Fast path: react immediately to new tasks. porsager reserves a dedicated
|
||||||
|
// connection and auto-resubscribes on reconnect; the onlisten callback
|
||||||
|
// fires on each (re)subscribe, so we kick a catch-up poll there too to
|
||||||
|
// sweep up anything inserted during a disconnect.
|
||||||
|
sql
|
||||||
|
.listen(
|
||||||
|
'tasks_new',
|
||||||
|
() => triggerPoll('notify'),
|
||||||
|
() => triggerPoll('listen-subscribed'),
|
||||||
|
)
|
||||||
|
.then((meta) => {
|
||||||
|
listener = meta;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
|
||||||
});
|
});
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
@@ -374,6 +506,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
if (listener) {
|
||||||
|
await listener.unlisten().catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: unlisten error');
|
||||||
|
});
|
||||||
|
listener = null;
|
||||||
|
}
|
||||||
if (inflightPromise) {
|
if (inflightPromise) {
|
||||||
log.info('dispatcher: waiting for in-flight task');
|
log.info('dispatcher: waiting for in-flight task');
|
||||||
await inflightPromise;
|
await inflightPromise;
|
||||||
|
|||||||
66
apps/coder/src/services/host-exec.ts
Normal file
66
apps/coder/src/services/host-exec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Local shell exec on the BooCoder host (replaces deprecated ssh.ts for worktrees).
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
export interface HostExecResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostExec(
|
||||||
|
command: string,
|
||||||
|
opts?: { signal?: AbortSignal; timeoutMs?: number },
|
||||||
|
): Promise<HostExecResult> {
|
||||||
|
return new Promise<HostExecResult>((resolve, reject) => {
|
||||||
|
const child = spawn('bash', ['-lc', command], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let killed = false;
|
||||||
|
|
||||||
|
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||||
|
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!killed) {
|
||||||
|
killed = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts?.signal) {
|
||||||
|
if (opts.signal.aborted) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('host exec aborted before start'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opts.signal.addEventListener('abort', cleanup, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
if (opts?.timeoutMs) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`host exec timed out after ${opts.timeoutMs}ms`));
|
||||||
|
}, opts.timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||||
|
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin!.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -57,14 +57,29 @@ export async function startMcpServer(sql: Sql): Promise<void> {
|
|||||||
input: z.string().describe('Task description / prompt for the agent'),
|
input: z.string().describe('Task description / prompt for the agent'),
|
||||||
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
|
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
|
||||||
model: z.string().optional().describe('Model override (optional)'),
|
model: z.string().optional().describe('Model override (optional)'),
|
||||||
|
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
||||||
|
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
const [row] = await sql<TaskRow[]>`
|
const [row] = await sql<TaskRow[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, state)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
|
||||||
VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending')
|
VALUES (
|
||||||
|
${args.project_id},
|
||||||
|
${args.input},
|
||||||
|
${args.agent ?? null},
|
||||||
|
${args.model ?? null},
|
||||||
|
${args.mode_id ?? null},
|
||||||
|
${args.thinking_option_id ?? null},
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
return textResult({ task_id: row!.id, state: row!.state });
|
return textResult({
|
||||||
|
task_id: row!.id,
|
||||||
|
state: row!.state,
|
||||||
|
mode_id: args.mode_id ?? null,
|
||||||
|
thinking_option_id: args.thinking_option_id ?? null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,11 +162,21 @@ export async function startMcpServer(sql: Sql): Promise<void> {
|
|||||||
input: z.string().describe('Task prompt'),
|
input: z.string().describe('Task prompt'),
|
||||||
agent: z.string().describe('Agent name (must match available_agents registry)'),
|
agent: z.string().describe('Agent name (must match available_agents registry)'),
|
||||||
model: z.string().optional().describe('Model override (optional)'),
|
model: z.string().optional().describe('Model override (optional)'),
|
||||||
|
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
||||||
|
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
const [row] = await sql<TaskRow[]>`
|
const [row] = await sql<TaskRow[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, state)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, state)
|
||||||
VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? null}, 'pending')
|
VALUES (
|
||||||
|
${args.project_id},
|
||||||
|
${args.input},
|
||||||
|
${args.agent},
|
||||||
|
${args.model ?? null},
|
||||||
|
${args.mode_id ?? null},
|
||||||
|
${args.thinking_option_id ?? null},
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -161,7 +186,13 @@ export async function startMcpServer(sql: Sql): Promise<void> {
|
|||||||
`;
|
`;
|
||||||
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
|
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
|
||||||
|
|
||||||
return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath });
|
return textResult({
|
||||||
|
task_id: row!.id,
|
||||||
|
state: row!.state,
|
||||||
|
execution_path: executionPath,
|
||||||
|
mode_id: args.mode_id ?? null,
|
||||||
|
thinking_option_id: args.thinking_option_id ?? null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
207
apps/coder/src/services/permission-waiter.ts
Normal file
207
apps/coder/src/services/permission-waiter.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
|
||||||
|
*/
|
||||||
|
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
|
||||||
|
import { isUnattendedMode } from './provider-manifest.js';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface PendingPermission {
|
||||||
|
type: 'permission';
|
||||||
|
request: RequestPermissionRequest;
|
||||||
|
sessionId: string;
|
||||||
|
resolve: (response: RequestPermissionResponse) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingElicitation {
|
||||||
|
type: 'elicitation';
|
||||||
|
request: CreateElicitationRequest;
|
||||||
|
sessionId: string;
|
||||||
|
resolve: (response: CreateElicitationResponse) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingEntry = PendingPermission | PendingElicitation;
|
||||||
|
|
||||||
|
const pendingByTask = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
|
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||||
|
|
||||||
|
export interface PermissionPrompt {
|
||||||
|
taskId: string;
|
||||||
|
kind: PermissionKind;
|
||||||
|
toolTitle?: string;
|
||||||
|
description?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
options: Array<{ optionId: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionHooks {
|
||||||
|
onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise<void>;
|
||||||
|
onResolved?: (taskId: string, sessionId: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hooks: PermissionHooks = {};
|
||||||
|
|
||||||
|
export function setPermissionHooks(next: PermissionHooks): void {
|
||||||
|
hooks = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKind(params: RequestPermissionRequest): PermissionKind {
|
||||||
|
const input = params.toolCall?.rawInput;
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
|
||||||
|
return 'question';
|
||||||
|
}
|
||||||
|
return 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||||
|
const kind = resolveKind(params);
|
||||||
|
const rawInput = params.toolCall?.rawInput;
|
||||||
|
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||||
|
? rawInput as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
kind,
|
||||||
|
toolTitle: params.toolCall?.title ?? undefined,
|
||||||
|
...(input ? { input } : {}),
|
||||||
|
options: params.options.map((o) => ({
|
||||||
|
optionId: o.optionId,
|
||||||
|
label: o.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForPermissionResponse(
|
||||||
|
taskId: string,
|
||||||
|
sessionId: string,
|
||||||
|
provider: string,
|
||||||
|
modeId: string | undefined,
|
||||||
|
params: RequestPermissionRequest,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<RequestPermissionResponse> {
|
||||||
|
if (isUnattendedMode(provider, modeId)) {
|
||||||
|
const first = params.options[0];
|
||||||
|
if (first) {
|
||||||
|
return Promise.resolve({ outcome: { outcome: 'selected', optionId: first.optionId } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = pendingByTask.get(taskId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.reject(new Error('superseded by newer permission request'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingByTask.delete(taskId);
|
||||||
|
void hooks.onResolved?.(taskId, sessionId);
|
||||||
|
resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
|
||||||
|
|
||||||
|
const prompt = toPrompt(taskId, params);
|
||||||
|
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
|
||||||
|
const pending = pendingByTask.get(taskId);
|
||||||
|
if (!pending) return false;
|
||||||
|
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pendingByTask.delete(taskId);
|
||||||
|
|
||||||
|
if (pending.type === 'elicitation') {
|
||||||
|
if (updatedInput) {
|
||||||
|
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
|
||||||
|
pending.resolve({ action: 'accept', content });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ action: 'decline' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (optionId) {
|
||||||
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||||
|
const pending = pendingByTask.get(taskId);
|
||||||
|
if (!pending) return null;
|
||||||
|
if (pending.type === 'elicitation') {
|
||||||
|
return elicitationToPrompt(taskId, pending.request);
|
||||||
|
}
|
||||||
|
return toPrompt(taskId, pending.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
|
||||||
|
const input: Record<string, unknown> = { message: params.message };
|
||||||
|
if ('requestedSchema' in params) {
|
||||||
|
input.requestedSchema = params.requestedSchema;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
kind: 'elicitation',
|
||||||
|
toolTitle: params.message,
|
||||||
|
input,
|
||||||
|
options: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForElicitationResponse(
|
||||||
|
taskId: string,
|
||||||
|
sessionId: string,
|
||||||
|
provider: string,
|
||||||
|
modeId: string | undefined,
|
||||||
|
params: CreateElicitationRequest,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<CreateElicitationResponse> {
|
||||||
|
if (isUnattendedMode(provider, modeId)) {
|
||||||
|
return Promise.resolve({ action: 'decline' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = pendingByTask.get(taskId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.reject(new Error('superseded by newer elicitation request'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingByTask.delete(taskId);
|
||||||
|
void hooks.onResolved?.(taskId, sessionId);
|
||||||
|
resolve({ action: 'cancel' });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
|
||||||
|
|
||||||
|
const prompt = elicitationToPrompt(taskId, params);
|
||||||
|
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelPendingPermission(taskId: string): void {
|
||||||
|
const pending = pendingByTask.get(taskId);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pendingByTask.delete(taskId);
|
||||||
|
if (pending.type === 'elicitation') {
|
||||||
|
pending.resolve({ action: 'cancel' });
|
||||||
|
} else {
|
||||||
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||||
|
}
|
||||||
|
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||||
|
}
|
||||||
66
apps/coder/src/services/provider-commands.ts
Normal file
66
apps/coder/src/services/provider-commands.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Static slash-command hints per harness (interactive TUI / agent session).
|
||||||
|
* Live ACP `available_commands_update` merges on top during dispatch.
|
||||||
|
*/
|
||||||
|
import type { AgentCommand } from './provider-types.js';
|
||||||
|
|
||||||
|
const CLAUDE_COMMANDS: AgentCommand[] = [
|
||||||
|
{ name: 'help', description: 'Show available slash commands' },
|
||||||
|
{ name: 'clear', description: 'Clear conversation history' },
|
||||||
|
{ name: 'compact', description: 'Compact context window' },
|
||||||
|
{ name: 'cost', description: 'Show session cost' },
|
||||||
|
{ name: 'memory', description: 'Manage project memory' },
|
||||||
|
{ name: 'model', description: 'Switch model' },
|
||||||
|
{ name: 'permissions', description: 'View or change permission mode' },
|
||||||
|
{ name: 'review', description: 'Review current changes' },
|
||||||
|
{ name: 'status', description: 'Show session status' },
|
||||||
|
{ name: 'vim', description: 'Toggle vim-style input' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPENCODE_COMMANDS: AgentCommand[] = [
|
||||||
|
{ name: 'help', description: 'Show available commands' },
|
||||||
|
{ name: 'new', description: 'Start a new session' },
|
||||||
|
{ name: 'models', description: 'List or switch models' },
|
||||||
|
{ name: 'agents', description: 'List or switch agents' },
|
||||||
|
{ name: 'compact', description: 'Compact context' },
|
||||||
|
{ name: 'share', description: 'Share session' },
|
||||||
|
{ name: 'export', description: 'Export session' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GOOSE_COMMANDS: AgentCommand[] = [
|
||||||
|
{ name: 'help', description: 'Show available commands' },
|
||||||
|
{ name: 'clear', description: 'Clear conversation' },
|
||||||
|
{ name: 'compact', description: 'Compact context' },
|
||||||
|
{ name: 'exit', description: 'Exit session' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const QWEN_COMMANDS: AgentCommand[] = [
|
||||||
|
{ name: 'help', description: 'Show available slash commands' },
|
||||||
|
{ name: 'clear', description: 'Clear conversation' },
|
||||||
|
{ name: 'memory', description: 'Manage memory' },
|
||||||
|
{ name: 'hooks', description: 'Manage hooks' },
|
||||||
|
{ name: 'review', description: 'Review changes' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** boocode harness uses /api/skills — merged on the frontend. */
|
||||||
|
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
||||||
|
claude: CLAUDE_COMMANDS,
|
||||||
|
opencode: OPENCODE_COMMANDS,
|
||||||
|
goose: GOOSE_COMMANDS,
|
||||||
|
qwen: QWEN_COMMANDS,
|
||||||
|
boocode: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getManifestCommands(provider: string): AgentCommand[] {
|
||||||
|
return PROVIDER_COMMANDS[provider] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCommands(...lists: AgentCommand[][]): AgentCommand[] {
|
||||||
|
const byName = new Map<string, AgentCommand>();
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const cmd of list) {
|
||||||
|
byName.set(cmd.name, cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
75
apps/coder/src/services/provider-manifest.ts
Normal file
75
apps/coder/src/services/provider-manifest.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Static provider mode metadata — lifted from Paseo provider-manifest.ts patterns.
|
||||||
|
*/
|
||||||
|
import type { ProviderMode } from './provider-types.js';
|
||||||
|
|
||||||
|
export interface ProviderManifestEntry {
|
||||||
|
defaultModeId: string | null;
|
||||||
|
modes: ProviderMode[];
|
||||||
|
/** Claude effort levels exposed as thinking options on models. */
|
||||||
|
thinkingOptions?: Array<{ id: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_MODES: ProviderMode[] = [
|
||||||
|
{ id: 'default', label: 'Always Ask', description: 'Prompts for permission the first time a tool is used' },
|
||||||
|
{ id: 'auto', label: 'Auto mode', description: 'Model classifier reviews permission prompts automatically' },
|
||||||
|
{ id: 'acceptEdits', label: 'Accept File Edits', description: 'Automatically approves edit-focused tools' },
|
||||||
|
{ id: 'plan', label: 'Plan Mode', description: 'Analyze without executing tools or edits' },
|
||||||
|
{ id: 'bypassPermissions', label: 'Bypass', description: 'Skip all permission prompts', isUnattended: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPENCODE_MODES: ProviderMode[] = [
|
||||||
|
{ id: 'build', label: 'Build', description: 'Allows edits and tool execution' },
|
||||||
|
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
|
||||||
|
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const QWEN_PTY_MODES: ProviderMode[] = [
|
||||||
|
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
||||||
|
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
||||||
|
{ id: 'auto-edit', label: 'Auto Edit', description: 'Auto-approve edit tools' },
|
||||||
|
{ id: 'auto', label: 'Auto', description: 'LLM classifier auto-approves safe actions' },
|
||||||
|
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLAUDE_THINKING = [
|
||||||
|
{ id: 'low', label: 'Low' },
|
||||||
|
{ id: 'medium', label: 'Medium' },
|
||||||
|
{ id: 'high', label: 'High' },
|
||||||
|
{ id: 'xhigh', label: 'Extra High' },
|
||||||
|
{ id: 'max', label: 'Max' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||||
|
claude: {
|
||||||
|
defaultModeId: 'default',
|
||||||
|
modes: CLAUDE_MODES,
|
||||||
|
thinkingOptions: CLAUDE_THINKING,
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
defaultModeId: 'build',
|
||||||
|
modes: OPENCODE_MODES,
|
||||||
|
},
|
||||||
|
goose: {
|
||||||
|
defaultModeId: null,
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
defaultModeId: 'default',
|
||||||
|
modes: QWEN_PTY_MODES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getManifestModes(provider: string): ProviderMode[] {
|
||||||
|
return PROVIDER_MANIFEST[provider]?.modes ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManifestDefaultModeId(provider: string): string | null {
|
||||||
|
return PROVIDER_MANIFEST[provider]?.defaultModeId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnattendedMode(provider: string, modeId: string | undefined): boolean {
|
||||||
|
if (!modeId) return false;
|
||||||
|
const modes = getManifestModes(provider);
|
||||||
|
return modes.some((m) => m.id === modeId && m.isUnattended);
|
||||||
|
}
|
||||||
60
apps/coder/src/services/provider-registry.ts
Normal file
60
apps/coder/src/services/provider-registry.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface ProviderDef {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
transport: 'native' | 'acp' | 'pty';
|
||||||
|
modelSource: 'llama-swap' | 'static' | 'probe';
|
||||||
|
staticModels?: Array<{ id: string; label: string }>;
|
||||||
|
/** Merge llama-swap models into probed list (OpenCode). */
|
||||||
|
mergeLlamaSwap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model discovery rules (see provider-snapshot.ts):
|
||||||
|
* - boocode: llama-swap only
|
||||||
|
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
||||||
|
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
||||||
|
* - goose: ACP probe only
|
||||||
|
* - claude: static manifest models + thinking options
|
||||||
|
*/
|
||||||
|
export const PROVIDERS: ProviderDef[] = [
|
||||||
|
{
|
||||||
|
name: 'boocode',
|
||||||
|
label: 'BooCoder',
|
||||||
|
transport: 'native',
|
||||||
|
modelSource: 'llama-swap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opencode',
|
||||||
|
label: 'OpenCode',
|
||||||
|
transport: 'acp',
|
||||||
|
modelSource: 'probe',
|
||||||
|
mergeLlamaSwap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
modelSource: 'probe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'claude',
|
||||||
|
label: 'Claude Code',
|
||||||
|
transport: 'pty',
|
||||||
|
modelSource: 'static',
|
||||||
|
staticModels: [
|
||||||
|
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||||
|
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen',
|
||||||
|
label: 'Qwen Code',
|
||||||
|
transport: 'acp',
|
||||||
|
modelSource: 'probe',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||||
|
|
||||||
|
/** External agents probed on host (excludes native boocode). */
|
||||||
|
export const PROBED_AGENT_NAMES = PROVIDERS.filter((p) => p.name !== 'boocode').map((p) => p.name);
|
||||||
250
apps/coder/src/services/provider-snapshot.ts
Normal file
250
apps/coder/src/services/provider-snapshot.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
||||||
|
*/
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||||
|
import {
|
||||||
|
getManifestDefaultModeId,
|
||||||
|
getManifestModes,
|
||||||
|
PROVIDER_MANIFEST,
|
||||||
|
} from './provider-manifest.js';
|
||||||
|
import { probeAcpProvider } from './acp-probe.js';
|
||||||
|
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||||
|
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||||
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
|
|
||||||
|
interface AgentRow {
|
||||||
|
name: string;
|
||||||
|
install_path: string | null;
|
||||||
|
supports_acp: boolean;
|
||||||
|
models: ProviderModel[] | null;
|
||||||
|
label: string | null;
|
||||||
|
transport: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
|
||||||
|
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
||||||
|
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
||||||
|
return models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
|
||||||
|
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
|
||||||
|
if (!thinking?.length) return models;
|
||||||
|
return models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
thinkingOptions: thinking,
|
||||||
|
defaultThinkingOptionId: 'medium',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: ProviderModel[] = [];
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const m of list) {
|
||||||
|
if (seen.has(m.id)) continue;
|
||||||
|
seen.add(m.id);
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildProviderEntry(
|
||||||
|
provider: ProviderDef,
|
||||||
|
agentRow: AgentRow | undefined,
|
||||||
|
llamaModels: ProviderModel[],
|
||||||
|
cwd: string,
|
||||||
|
): Promise<ProviderSnapshotEntry | null> {
|
||||||
|
const isNative = provider.name === 'boocode';
|
||||||
|
const installed = isNative || !!agentRow;
|
||||||
|
if (!installed) return null;
|
||||||
|
|
||||||
|
let transport = provider.transport;
|
||||||
|
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
||||||
|
transport = 'pty';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackModes = getManifestModes(provider.name);
|
||||||
|
const defaultModeId = getManifestDefaultModeId(provider.name);
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
label: provider.label,
|
||||||
|
transport,
|
||||||
|
status: 'ready',
|
||||||
|
installed: true,
|
||||||
|
models: llamaModels,
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: getManifestCommands(provider.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let models: ProviderModel[] = [];
|
||||||
|
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
||||||
|
models = llamaModels;
|
||||||
|
} else if (agentRow?.models?.length) {
|
||||||
|
models = agentRow.models;
|
||||||
|
} else if (provider.staticModels) {
|
||||||
|
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.name === 'claude') {
|
||||||
|
models = attachClaudeThinking(models);
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
label: agentRow?.label ?? provider.label,
|
||||||
|
transport,
|
||||||
|
status: 'ready',
|
||||||
|
installed: true,
|
||||||
|
models,
|
||||||
|
modes: fallbackModes,
|
||||||
|
defaultModeId,
|
||||||
|
commands: getManifestCommands(provider.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
||||||
|
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
||||||
|
if (probe.models.length > 0) {
|
||||||
|
models = probe.models;
|
||||||
|
} else if (provider.modelSource === 'llama-swap') {
|
||||||
|
models = llamaModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.name === 'qwen') {
|
||||||
|
const settingsModels = await readQwenSettingsModels();
|
||||||
|
models = mergeModels(models, settingsModels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
||||||
|
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
||||||
|
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
label: agentRow.label ?? provider.label,
|
||||||
|
transport,
|
||||||
|
status: probe.ok ? 'ready' : 'error',
|
||||||
|
installed: true,
|
||||||
|
models,
|
||||||
|
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||||
|
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||||
|
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
||||||
|
error: probe.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTY-only providers (qwen fallback when ACP unavailable)
|
||||||
|
if (provider.name === 'qwen') {
|
||||||
|
if (models.length === 0) {
|
||||||
|
models = await readQwenSettingsModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
label: agentRow?.label ?? provider.label,
|
||||||
|
transport,
|
||||||
|
status: 'ready',
|
||||||
|
installed: true,
|
||||||
|
models,
|
||||||
|
modes: fallbackModes,
|
||||||
|
defaultModeId,
|
||||||
|
commands: getManifestCommands(provider.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
||||||
|
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
||||||
|
const CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
export async function getProviderSnapshot(
|
||||||
|
sql: Sql,
|
||||||
|
config: Config,
|
||||||
|
cwd?: string,
|
||||||
|
force = false,
|
||||||
|
): Promise<ProviderSnapshotEntry[]> {
|
||||||
|
const resolvedCwd = cwd?.trim() || homedir();
|
||||||
|
const cacheKey = resolvedCwd;
|
||||||
|
const cached = snapshotCache.get(cacheKey);
|
||||||
|
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
|
return cached.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = snapshotInflight.get(cacheKey);
|
||||||
|
if (!force && inflight) {
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||||
|
const llamaModels = await fetchLlamaSwapModels(config);
|
||||||
|
const agents = await sql<AgentRow[]>`
|
||||||
|
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
||||||
|
`;
|
||||||
|
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||||
|
|
||||||
|
const built = await Promise.all(
|
||||||
|
PROVIDERS.map((provider) =>
|
||||||
|
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
||||||
|
|
||||||
|
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = build().finally(() => {
|
||||||
|
snapshotInflight.delete(cacheKey);
|
||||||
|
});
|
||||||
|
snapshotInflight.set(cacheKey, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProviderSnapshotCache(): void {
|
||||||
|
snapshotCache.clear();
|
||||||
|
snapshotInflight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||||
|
export async function persistProbedModels(
|
||||||
|
sql: Sql,
|
||||||
|
entries: ProviderSnapshotEntry[],
|
||||||
|
log: FastifyBaseLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
||||||
|
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||||
|
await sql`
|
||||||
|
UPDATE available_agents
|
||||||
|
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||||
|
WHERE name = ${entry.name}
|
||||||
|
`;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/coder/src/services/provider-types.ts
Normal file
51
apps/coder/src/services/provider-types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
|
||||||
|
|
||||||
|
export interface ProviderMode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
/** Auto-approve tool permissions when this mode is selected. */
|
||||||
|
isUnattended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThinkingOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderModel {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
thinkingOptions?: ThinkingOption[];
|
||||||
|
defaultThinkingOptionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface AgentCommand {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderSnapshotEntry {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
transport: string;
|
||||||
|
status: ProviderSnapshotStatus;
|
||||||
|
installed: boolean;
|
||||||
|
models: ProviderModel[];
|
||||||
|
modes: ProviderMode[];
|
||||||
|
defaultModeId: string | null;
|
||||||
|
commands: AgentCommand[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSessionConfig {
|
||||||
|
provider: string;
|
||||||
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
}
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* PTY dispatch — runs external agents on the host via SSH.
|
* PTY dispatch — runs external agents directly on the host.
|
||||||
*
|
|
||||||
* For agents without ACP support (claude, pi), we pipe the task into their
|
|
||||||
* non-interactive mode and capture stdout/stderr. The agent runs in a git
|
|
||||||
* worktree so it can modify files freely.
|
|
||||||
*
|
|
||||||
* Supported agents:
|
|
||||||
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
|
|
||||||
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD)
|
|
||||||
* - goose: stub (not yet supported)
|
|
||||||
* - pi: stub (not yet supported)
|
|
||||||
*/
|
*/
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import { sshSpawnWithStdin } from './ssh.js';
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
export interface DispatchResult {
|
export interface DispatchResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
@@ -25,56 +15,68 @@ export interface PtyDispatchOpts {
|
|||||||
task: string;
|
task: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
installPath?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface PtySpawnSpec {
|
||||||
* Build the shell command that runs the agent non-interactively.
|
binary: string;
|
||||||
* The command will be executed inside `cd <worktreePath> && ...`.
|
args: string[];
|
||||||
*/
|
stdin?: string;
|
||||||
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
|
}
|
||||||
// Escape the task for embedding in a shell command
|
|
||||||
const escapedTask = task.replace(/'/g, "'\\''");
|
function buildPtySpawnSpec(
|
||||||
|
agent: string,
|
||||||
|
task: string,
|
||||||
|
model?: string,
|
||||||
|
modeId?: string,
|
||||||
|
thinkingOptionId?: string,
|
||||||
|
installPath?: string,
|
||||||
|
): PtySpawnSpec | null {
|
||||||
|
const binary = installPath ?? agent;
|
||||||
|
|
||||||
switch (agent) {
|
switch (agent) {
|
||||||
case 'claude':
|
case 'claude': {
|
||||||
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
|
const args = ['-p'];
|
||||||
return model
|
if (model) args.push('--model', model);
|
||||||
? `echo '${escapedTask}' | claude -p --model '${model}'`
|
if (modeId) args.push('--permission-mode', modeId);
|
||||||
: `echo '${escapedTask}' | claude -p`;
|
if (thinkingOptionId) args.push('--effort', thinkingOptionId);
|
||||||
|
return { binary, args, stdin: task };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qwen': {
|
||||||
|
const args = ['-p', task, '--output-format', 'stream-json'];
|
||||||
|
if (model) args.push('--model', model);
|
||||||
|
if (modeId) args.push('--approval-mode', modeId);
|
||||||
|
return { binary, args };
|
||||||
|
}
|
||||||
|
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
// opencode non-interactive: pipe task via stdin
|
return {
|
||||||
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
|
binary,
|
||||||
return model
|
args: model ? ['--model', model] : [],
|
||||||
? `echo '${escapedTask}' | opencode --model '${model}'`
|
stdin: task,
|
||||||
: `echo '${escapedTask}' | opencode`;
|
};
|
||||||
|
|
||||||
case 'goose':
|
case 'goose':
|
||||||
// Not yet verified for non-interactive use
|
return {
|
||||||
return null;
|
binary,
|
||||||
|
args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
|
||||||
case 'pi':
|
};
|
||||||
// Not yet verified for non-interactive use
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch a task to an external agent via SSH.
|
|
||||||
*
|
|
||||||
* The agent runs in the worktree directory on the host. stdout/stderr are
|
|
||||||
* captured in full and returned. The SSH process is killed on abort signal.
|
|
||||||
*/
|
|
||||||
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
||||||
const { agent, task, worktreePath, model, signal, log } = opts;
|
const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts;
|
||||||
|
|
||||||
const agentCmd = buildAgentCommand(agent, task, model);
|
const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
|
||||||
if (!agentCmd) {
|
if (!cmd) {
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
stdout: '',
|
stdout: '',
|
||||||
@@ -82,22 +84,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap in cd to the worktree
|
log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
|
||||||
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
|
|
||||||
|
|
||||||
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
|
|
||||||
|
|
||||||
return new Promise<DispatchResult>((resolve, reject) => {
|
return new Promise<DispatchResult>((resolve, reject) => {
|
||||||
const child = sshSpawnWithStdin(fullCommand, '');
|
const child = spawn(cmd.binary, cmd.args, {
|
||||||
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
|
cwd: worktreePath,
|
||||||
// stdin via echo piping, the command itself handles the piping on the remote
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
// side. We just need the SSH tunnel.
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
|
if (cmd.stdin) {
|
||||||
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
|
child.stdin!.write(cmd.stdin);
|
||||||
// provides its own stdin. So we should use sshSpawn (no local stdin needed)
|
}
|
||||||
// or just let the empty stdin close — the remote shell handles piping internally.
|
child.stdin!.end();
|
||||||
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
|
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
@@ -110,7 +109,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
|||||||
if (!killed) {
|
if (!killed) {
|
||||||
killed = true;
|
killed = true;
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
// Give it a moment then force-kill
|
|
||||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
21
apps/coder/src/services/qwen-settings.ts
Normal file
21
apps/coder/src/services/qwen-settings.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import type { ProviderModel } from './provider-types.js';
|
||||||
|
|
||||||
|
const QWEN_SETTINGS_PATH = join(homedir(), '.qwen', 'settings.json');
|
||||||
|
|
||||||
|
export async function readQwenSettingsModels(): Promise<ProviderModel[]> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(QWEN_SETTINGS_PATH, 'utf8');
|
||||||
|
if (!raw.trim()) return [];
|
||||||
|
const settings = JSON.parse(raw) as {
|
||||||
|
modelProviders?: { openai?: Array<{ id: string }> };
|
||||||
|
};
|
||||||
|
const openaiModels = settings?.modelProviders?.openai;
|
||||||
|
if (!Array.isArray(openaiModels)) return [];
|
||||||
|
return openaiModels.map((m) => ({ id: m.id, label: m.id }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSH helper — spawns commands on the host via SSH.
|
|
||||||
*
|
|
||||||
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).
|
|
||||||
* They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the
|
|
||||||
* Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53).
|
|
||||||
*/
|
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
|
||||||
|
|
||||||
export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53';
|
|
||||||
export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop';
|
|
||||||
|
|
||||||
/** Common SSH args — strict host checking disabled for container-to-host trust. */
|
|
||||||
const SSH_BASE_ARGS = [
|
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
|
||||||
'-o', 'LogLevel=ERROR',
|
|
||||||
'-o', 'BatchMode=yes',
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface SshExecResult {
|
|
||||||
exitCode: number;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a command on the host via SSH, collecting all output.
|
|
||||||
* Returns when the remote process exits.
|
|
||||||
*/
|
|
||||||
export async function sshExec(
|
|
||||||
command: string,
|
|
||||||
opts?: { signal?: AbortSignal; timeoutMs?: number },
|
|
||||||
): Promise<SshExecResult> {
|
|
||||||
return new Promise<SshExecResult>((resolve, reject) => {
|
|
||||||
const child = spawn('ssh', [
|
|
||||||
...SSH_BASE_ARGS,
|
|
||||||
`${SSH_USER}@${SSH_HOST}`,
|
|
||||||
command,
|
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let killed = false;
|
|
||||||
|
|
||||||
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
||||||
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (!killed) {
|
|
||||||
killed = true;
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Abort signal
|
|
||||||
if (opts?.signal) {
|
|
||||||
if (opts.signal.aborted) {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error('SSH exec aborted before start'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
opts.signal.addEventListener('abort', cleanup, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
if (opts?.timeoutMs) {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`));
|
|
||||||
}, opts.timeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
|
||||||
resolve({ exitCode: code ?? 1, stdout, stderr });
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close stdin immediately — we're not sending input via sshExec
|
|
||||||
child.stdin!.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn an SSH child process with a command on the host.
|
|
||||||
* Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY).
|
|
||||||
*/
|
|
||||||
export function sshSpawn(command: string): ChildProcess {
|
|
||||||
return spawn('ssh', [
|
|
||||||
...SSH_BASE_ARGS,
|
|
||||||
`${SSH_USER}@${SSH_HOST}`,
|
|
||||||
command,
|
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn an SSH child process that pipes stdin through.
|
|
||||||
* Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`).
|
|
||||||
*/
|
|
||||||
export function sshSpawnWithStdin(command: string, input: string): ChildProcess {
|
|
||||||
const child = spawn('ssh', [
|
|
||||||
...SSH_BASE_ARGS,
|
|
||||||
`${SSH_USER}@${SSH_HOST}`,
|
|
||||||
command,
|
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write the input and close stdin
|
|
||||||
child.stdin!.write(input);
|
|
||||||
child.stdin!.end();
|
|
||||||
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* After the agent completes, we diff the worktree against HEAD and
|
* After the agent completes, we diff the worktree against HEAD and
|
||||||
* queue the diff into pending_changes.
|
* queue the diff into pending_changes.
|
||||||
*/
|
*/
|
||||||
import { sshExec } from './ssh.js';
|
import { hostExec } from './host-exec.js';
|
||||||
|
|
||||||
const WORKTREE_BASE = '/tmp/booworktrees';
|
const WORKTREE_BASE = '/tmp/booworktrees';
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ export async function createWorktree(
|
|||||||
const branchName = `task-${taskId}`;
|
const branchName = `task-${taskId}`;
|
||||||
|
|
||||||
// Ensure the base directory exists
|
// Ensure the base directory exists
|
||||||
await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
||||||
|
|
||||||
// Create the worktree with a new branch from HEAD
|
// Create the worktree with a new branch from HEAD
|
||||||
const result = await sshExec(
|
const result = await hostExec(
|
||||||
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
||||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
);
|
);
|
||||||
@@ -49,7 +49,7 @@ export async function diffWorktree(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// First, commit any uncommitted changes in the worktree so we can diff branches
|
// First, commit any uncommitted changes in the worktree so we can diff branches
|
||||||
// Stage all changes
|
// Stage all changes
|
||||||
const addResult = await sshExec(
|
const addResult = await hostExec(
|
||||||
`cd ${shellEscape(worktreePath)} && git add -A`,
|
`cd ${shellEscape(worktreePath)} && git add -A`,
|
||||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
);
|
);
|
||||||
@@ -58,7 +58,7 @@ export async function diffWorktree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are staged changes
|
// Check if there are staged changes
|
||||||
const statusResult = await sshExec(
|
const statusResult = await hostExec(
|
||||||
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
|
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
|
||||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
);
|
);
|
||||||
@@ -69,13 +69,13 @@ export async function diffWorktree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit staged changes (needed to produce a clean branch diff)
|
// Commit staged changes (needed to produce a clean branch diff)
|
||||||
await sshExec(
|
await hostExec(
|
||||||
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
|
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
|
||||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
||||||
const diffResult = await sshExec(
|
const diffResult = await hostExec(
|
||||||
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||||
{ signal: opts?.signal, timeoutMs: 60_000 },
|
{ signal: opts?.signal, timeoutMs: 60_000 },
|
||||||
);
|
);
|
||||||
@@ -99,13 +99,13 @@ export async function cleanupWorktree(
|
|||||||
const branchName = `task-${taskId}`;
|
const branchName = `task-${taskId}`;
|
||||||
|
|
||||||
// Remove the worktree (--force handles dirty state)
|
// Remove the worktree (--force handles dirty state)
|
||||||
await sshExec(
|
await hostExec(
|
||||||
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
|
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
|
||||||
{ timeoutMs: 15_000 },
|
{ timeoutMs: 15_000 },
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|
||||||
// Delete the task branch
|
// Delete the task branch
|
||||||
await sshExec(
|
await hostExec(
|
||||||
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
|
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
|
||||||
{ timeoutMs: 10_000 },
|
{ timeoutMs: 10_000 },
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ export function isSecretPath(filePath: string): boolean {
|
|||||||
* checks the result stays within projectRoot.
|
* checks the result stays within projectRoot.
|
||||||
*/
|
*/
|
||||||
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||||
if (!filePath || filePath.length === 0) {
|
if (!filePath || filePath.trim().length === 0) {
|
||||||
throw new WriteGuardError('file path is required');
|
throw new WriteGuardError('file path is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filePath.includes('\x00')) {
|
||||||
|
throw new WriteGuardError('file path contains null byte');
|
||||||
|
}
|
||||||
|
|
||||||
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||||
const normalized = resolve(candidate); // normalizes ../ segments
|
const normalized = resolve(candidate); // normalizes ../ segments
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Project, Session, Chat, Message, PendingChange } from './types';
|
import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -52,6 +52,14 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
}),
|
}),
|
||||||
|
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
|
||||||
|
request<{ tool_message_id: string; assistant_message_id: string }>(
|
||||||
|
`/api/chats/${chatId}/answer_user_input`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
|
|||||||
@@ -32,16 +32,37 @@ export interface Chat {
|
|||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
args: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
tool_call_id: string;
|
tool_call_id: string;
|
||||||
output: string;
|
output: unknown;
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
|
||||||
|
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
|
||||||
|
// same order. AskUserInputCard renders questions and POSTs answers.
|
||||||
|
export type AskUserQuestionType = 'single_select' | 'multi_select';
|
||||||
|
|
||||||
|
export interface AskUserQuestion {
|
||||||
|
question: string;
|
||||||
|
type: AskUserQuestionType;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskUserAnswer {
|
||||||
|
question: string;
|
||||||
|
selected_options: string[];
|
||||||
|
free_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskUserAnswerSet {
|
||||||
|
answers: AskUserAnswer[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|||||||
323
apps/coder/web/src/components/AskUserInputCard.tsx
Normal file
323
apps/coder/web/src/components/AskUserInputCard.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type {
|
||||||
|
AskUserAnswer,
|
||||||
|
AskUserAnswerSet,
|
||||||
|
AskUserQuestion,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
} from '@/api/types';
|
||||||
|
|
||||||
|
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
|
||||||
|
// the standard ToolCallLine when the assistant emits an ask_user_input tool
|
||||||
|
// call. While the tool result is null (server pre-stamps a sentinel with
|
||||||
|
// output=null), shows the form; once the WS tool_result frame arrives with a
|
||||||
|
// real AnswerSet, flips to read-only review mode.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
toolResult: ToolResult | null;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||||
|
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
|
||||||
|
const arr = (raw as { questions: unknown }).questions;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
const out: AskUserQuestion[] = [];
|
||||||
|
for (const item of arr) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const q = item as { question?: unknown; type?: unknown; options?: unknown };
|
||||||
|
if (typeof q.question !== 'string') continue;
|
||||||
|
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
|
||||||
|
if (!Array.isArray(q.options)) continue;
|
||||||
|
const opts = q.options.filter((o): o is string => typeof o === 'string');
|
||||||
|
if (opts.length < 2) continue;
|
||||||
|
out.push({ question: q.question, type: q.type, options: opts });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
|
||||||
|
const arr = (raw as { answers: unknown }).answers;
|
||||||
|
if (!Array.isArray(arr)) return null;
|
||||||
|
const answers: AskUserAnswer[] = [];
|
||||||
|
for (const item of arr) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
|
||||||
|
if (typeof a.question !== 'string') continue;
|
||||||
|
if (!Array.isArray(a.selected_options)) continue;
|
||||||
|
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
|
||||||
|
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
|
||||||
|
answers.push({
|
||||||
|
question: a.question,
|
||||||
|
selected_options: sel,
|
||||||
|
free_text: (a.free_text as string | null) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { answers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||||
|
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||||
|
ask_user_input: malformed tool args
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool result with a non-null output means the answer is already submitted.
|
||||||
|
// The pending sentinel uses output=null, so this branch only triggers after
|
||||||
|
// the real WS tool_result frame lands.
|
||||||
|
const answered = toolResult && toolResult.output !== null;
|
||||||
|
if (answered) {
|
||||||
|
const answerSet = parseAnswerSet(toolResult!.output);
|
||||||
|
return <AnsweredView questions={questions} answers={answerSet} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingView({
|
||||||
|
questions,
|
||||||
|
toolCallId,
|
||||||
|
chatId,
|
||||||
|
}: {
|
||||||
|
questions: AskUserQuestion[];
|
||||||
|
toolCallId: string;
|
||||||
|
chatId: string;
|
||||||
|
}) {
|
||||||
|
// Per-question selections + free text. Selections are option arrays so the
|
||||||
|
// multi_select case is uniform; single_select just constrains to length 1.
|
||||||
|
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||||
|
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const singleQuestion = questions.length === 1;
|
||||||
|
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
|
||||||
|
|
||||||
|
// Submit button shows when:
|
||||||
|
// - more than one question (always batched), OR
|
||||||
|
// - one question and the user has typed free text (committing it needs an
|
||||||
|
// explicit Submit so an accidental Tab/click doesn't lose it).
|
||||||
|
// For one question with no free text, clicking an option submits inline.
|
||||||
|
const showSubmitButton = !singleQuestion || anyFreeText;
|
||||||
|
|
||||||
|
// Every question must have at least one of (option, free text).
|
||||||
|
const allComplete = questions.every((_, i) => {
|
||||||
|
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAnswers(): AskUserAnswer[] {
|
||||||
|
return questions.map((q, i) => {
|
||||||
|
const freeText = freeTexts[i]!.trim();
|
||||||
|
return {
|
||||||
|
question: q.question,
|
||||||
|
selected_options: selections[i]!,
|
||||||
|
free_text: freeText.length > 0 ? freeText : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(answers: AskUserAnswer[]) {
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||||
|
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||||
|
// into AnsweredView via the parent prop change.
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSingle(qIdx: number, option: string) {
|
||||||
|
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||||
|
// Immediate submit for the single-question single-select shortcut. Only
|
||||||
|
// fires when no free text exists anywhere — once the user typed, the
|
||||||
|
// Submit button takes over so the typed text isn't silently dropped.
|
||||||
|
if (singleQuestion && !anyFreeText) {
|
||||||
|
const answers: AskUserAnswer[] = [
|
||||||
|
{
|
||||||
|
question: questions[0]!.question,
|
||||||
|
selected_options: [option],
|
||||||
|
free_text: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
void submit(answers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMulti(qIdx: number, option: string) {
|
||||||
|
setSelections((prev) =>
|
||||||
|
prev.map((arr, i) => {
|
||||||
|
if (i !== qIdx) return arr;
|
||||||
|
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFreeText(qIdx: number, value: string) {
|
||||||
|
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-4">
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Question {i + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-medium leading-snug">{q.question}</div>
|
||||||
|
{q.type === 'single_select' ? (
|
||||||
|
<RadioGroup
|
||||||
|
value={selections[i]![0] ?? ''}
|
||||||
|
onValueChange={(v) => pickSingle(i, v)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
const checked = selections[i]!.includes(opt);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={() => toggleMulti(i, opt)}
|
||||||
|
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1 space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Or type a custom answer
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={freeTexts[i]}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Free text…"
|
||||||
|
onChange={(e) => setFreeText(i, e.target.value)}
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showSubmitButton && (
|
||||||
|
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!allComplete || submitting}
|
||||||
|
onClick={() => void submit(buildAnswers())}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnsweredView({
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
}: {
|
||||||
|
questions: AskUserQuestion[];
|
||||||
|
answers: AskUserAnswerSet | null;
|
||||||
|
}) {
|
||||||
|
if (!answers) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||||
|
ask_user_input: answers unavailable
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
{questions.map((q, i) => {
|
||||||
|
const a = answers.answers[i];
|
||||||
|
if (!a) return null;
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Question {i + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-medium leading-snug">{q.question}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const selected = a.selected_options.includes(opt);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className={
|
||||||
|
selected
|
||||||
|
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||||
|
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||||
|
{selected && <Check className="size-3 text-primary" />}
|
||||||
|
</span>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{a.free_text && (
|
||||||
|
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||||
|
{a.free_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Square } from 'lucide-react';
|
import { Send, Square } from 'lucide-react';
|
||||||
import type { Message } from '@/api/types';
|
import type { Message, ToolResult } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
|
|
||||||
@@ -66,6 +66,14 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
|
|||||||
// Filter out system messages for display (sentinels)
|
// Filter out system messages for display (sentinels)
|
||||||
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
||||||
|
|
||||||
|
// Build a lookup map from tool_call_id -> ToolResult for all messages
|
||||||
|
const toolResultsMap: Record<string, ToolResult> = {};
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.tool_results) {
|
||||||
|
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Connection indicator */}
|
{/* Connection indicator */}
|
||||||
@@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleMessages.map((msg) => (
|
{visibleMessages.map((msg) => (
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
<MessageBubble key={msg.id} message={msg} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import type { Message } from '@/api/types';
|
import type { Message, ToolResult } from '@/api/types';
|
||||||
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { AskUserInputCard } from './AskUserInputCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
chatId: string;
|
||||||
|
toolResultsMap: Record<string, ToolResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message }: Props) {
|
export function MessageBubble({ message, chatId }: Props) {
|
||||||
if (message.role === 'tool') {
|
if (message.role === 'tool') {
|
||||||
return <ToolResultBubble message={message} />;
|
return <ToolResultBubble message={message} />;
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,19 @@ export function MessageBubble({ message }: Props) {
|
|||||||
|
|
||||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||||
<div className="mb-2 space-y-1">
|
<div className="mb-2 space-y-1">
|
||||||
{message.tool_calls.map((tc) => (
|
{message.tool_calls.map((tc) => {
|
||||||
|
if (tc.name === 'ask_user_input') {
|
||||||
|
const result = message.tool_results ?? null;
|
||||||
|
return (
|
||||||
|
<AskUserInputCard
|
||||||
|
key={tc.id}
|
||||||
|
toolCall={tc}
|
||||||
|
toolResult={result}
|
||||||
|
chatId={chatId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={tc.id}
|
key={tc.id}
|
||||||
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||||
@@ -42,10 +57,11 @@ export function MessageBubble({ message }: Props) {
|
|||||||
<Wrench size={11} />
|
<Wrench size={11} />
|
||||||
<span className="font-mono">{tc.name}</span>
|
<span className="font-mono">{tc.name}</span>
|
||||||
<span className="text-zinc-500 truncate max-w-[200px]">
|
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||||
{truncateArgs(tc.arguments)}
|
{truncateArgs(tc.args)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -70,12 +86,12 @@ export function MessageBubble({ message }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolResultBubble({ message }: Props) {
|
function ToolResultBubble({ message }: { message: Message }) {
|
||||||
const result = message.tool_results;
|
const result = message.tool_results;
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
const isError = result.error;
|
const isError = result.error;
|
||||||
const output = result.output || '';
|
const output = result.output != null ? String(result.output) : '';
|
||||||
const displayOutput =
|
const displayOutput =
|
||||||
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
||||||
|
|
||||||
@@ -99,17 +115,21 @@ function ToolResultBubble({ message }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateArgs(args: string): string {
|
function truncateArgs(args: unknown): string {
|
||||||
if (!args) return '';
|
if (!args) return '';
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(args);
|
if (typeof args === 'object' && args !== null) {
|
||||||
const keys = Object.keys(parsed);
|
const obj = args as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj);
|
||||||
if (keys.length === 0) return '';
|
if (keys.length === 0) return '';
|
||||||
const first = keys[0]!;
|
const first = keys[0]!;
|
||||||
const val = String(parsed[first]);
|
const val = String(obj[first] ?? '');
|
||||||
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||||
return `${first}: ${display}`;
|
return `${first}: ${display}`;
|
||||||
|
}
|
||||||
|
const str = String(args);
|
||||||
|
return str.length > 50 ? str.slice(0, 50) + '...' : str;
|
||||||
} catch {
|
} catch {
|
||||||
return args.length > 50 ? args.slice(0, 50) + '...' : args;
|
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
apps/coder/web/src/components/ui/button.tsx
Normal file
35
apps/coder/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<string, string> = {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<string, string> = {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
|
||||||
|
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
|
||||||
|
return <button className={cls} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
56
apps/coder/web/src/components/ui/radio-group.tsx
Normal file
56
apps/coder/web/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const RadioGroupContext = React.createContext<{
|
||||||
|
value: string | undefined;
|
||||||
|
onValueChange: (v: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||||
|
({ className, value, onValueChange, disabled, ...props }, ref) => {
|
||||||
|
const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
|
||||||
|
return (
|
||||||
|
<RadioGroupContext.Provider value={ctx}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="radiogroup"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</RadioGroupContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
RadioGroup.displayName = 'RadioGroup';
|
||||||
|
|
||||||
|
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
||||||
|
({ className, value, ...props }, ref) => {
|
||||||
|
const ctx = React.useContext(RadioGroupContext);
|
||||||
|
if (!ctx) return <input ref={ref} type="radio" className={className} value={value} {...props} />;
|
||||||
|
const checked = ctx.value === value;
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="radio"
|
||||||
|
checked={checked}
|
||||||
|
disabled={ctx.disabled}
|
||||||
|
onChange={() => ctx.onValueChange(value)}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
RadioGroupItem.displayName = 'RadioGroupItem';
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -5,21 +5,74 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
".": {
|
||||||
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
"types": "./dist/index.d.ts",
|
||||||
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
"default": "./dist/index.js"
|
||||||
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
},
|
||||||
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
"./inference": {
|
||||||
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
"types": "./dist/services/inference/index.d.ts",
|
||||||
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
"default": "./dist/services/inference/index.js"
|
||||||
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
},
|
||||||
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
"./tools": {
|
||||||
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
"types": "./dist/services/tools.d.ts",
|
||||||
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
"default": "./dist/services/tools.js"
|
||||||
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
},
|
||||||
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
"./broker": {
|
||||||
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
"types": "./dist/services/broker.d.ts",
|
||||||
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
"default": "./dist/services/broker.js"
|
||||||
|
},
|
||||||
|
"./compaction": {
|
||||||
|
"types": "./dist/services/compaction.d.ts",
|
||||||
|
"default": "./dist/services/compaction.js"
|
||||||
|
},
|
||||||
|
"./model-context": {
|
||||||
|
"types": "./dist/services/model-context.d.ts",
|
||||||
|
"default": "./dist/services/model-context.js"
|
||||||
|
},
|
||||||
|
"./system-prompt": {
|
||||||
|
"types": "./dist/services/system-prompt.d.ts",
|
||||||
|
"default": "./dist/services/system-prompt.js"
|
||||||
|
},
|
||||||
|
"./agents": {
|
||||||
|
"types": "./dist/services/agents.d.ts",
|
||||||
|
"default": "./dist/services/agents.js"
|
||||||
|
},
|
||||||
|
"./truncate": {
|
||||||
|
"types": "./dist/services/truncate.d.ts",
|
||||||
|
"default": "./dist/services/truncate.js"
|
||||||
|
},
|
||||||
|
"./path-guard": {
|
||||||
|
"types": "./dist/services/path_guard.d.ts",
|
||||||
|
"default": "./dist/services/path_guard.js"
|
||||||
|
},
|
||||||
|
"./file-ops": {
|
||||||
|
"types": "./dist/services/file_ops.d.ts",
|
||||||
|
"default": "./dist/services/file_ops.js"
|
||||||
|
},
|
||||||
|
"./types": {
|
||||||
|
"types": "./dist/types/api.d.ts",
|
||||||
|
"default": "./dist/types/api.js"
|
||||||
|
},
|
||||||
|
"./ws-frames": {
|
||||||
|
"types": "./dist/types/ws-frames.d.ts",
|
||||||
|
"default": "./dist/types/ws-frames.js"
|
||||||
|
},
|
||||||
|
"./db": {
|
||||||
|
"types": "./dist/db.d.ts",
|
||||||
|
"default": "./dist/db.js"
|
||||||
|
},
|
||||||
|
"./config": {
|
||||||
|
"types": "./dist/config.d.ts",
|
||||||
|
"default": "./dist/config.js"
|
||||||
|
},
|
||||||
|
"./skills": {
|
||||||
|
"types": "./dist/services/skills.d.ts",
|
||||||
|
"default": "./dist/services/skills.js"
|
||||||
|
},
|
||||||
|
"./skill-invoke": {
|
||||||
|
"types": "./dist/services/skill-invoke.d.ts",
|
||||||
|
"default": "./dist/services/skill-invoke.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -34,6 +87,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ai": "^6.0.190",
|
"ai": "^6.0.190",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -44,5 +98,6 @@
|
|||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const ConfigSchema = z.object({
|
|||||||
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
||||||
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
||||||
MCP_CONFIG_PATH: z.string().optional(),
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
|
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||||
|
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||||
|
FAST_MODEL: z.string().optional(),
|
||||||
|
TASK_MODEL_URL: z.string().url().optional(),
|
||||||
|
LLAMA_SIDECAR_URL: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { registerArtifactRoutes } from './routes/artifacts.js';
|
|||||||
import { registerChatRoutes } from './routes/chats.js';
|
import { registerChatRoutes } from './routes/chats.js';
|
||||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
|
import { registerCoderProxy } from './routes/coder-proxy.js';
|
||||||
import { registerModelRoutes } from './routes/models.js';
|
import { registerModelRoutes } from './routes/models.js';
|
||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
import { registerSkillsRoutes } from './routes/skills.js';
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
@@ -27,7 +28,7 @@ import { cleanupTruncations } from './services/truncate.js';
|
|||||||
import { loadMcpConfig } from './services/mcp-config.js';
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
import { appendMcpTools } from './services/tools.js';
|
import { appendMcpTools } from './services/tools.js';
|
||||||
import { refreshToolNames } from './services/agents.js';
|
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -90,6 +91,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
|
// Boot-time guard: if any agent has llama_extra_args but LLAMA_SIDECAR_URL
|
||||||
|
// is unset, fail fast. Silent fallback would defeat per-agent flags.
|
||||||
|
if (!config.LLAMA_SIDECAR_URL) {
|
||||||
|
const { agents } = await getAgentsForProject('');
|
||||||
|
const offending = agents.find(a => a.llama_extra_args && a.llama_extra_args.length > 0);
|
||||||
|
if (offending) {
|
||||||
|
app.log.fatal(
|
||||||
|
{ agent: offending.name },
|
||||||
|
`Agent "${offending.name}" has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
@@ -212,36 +227,10 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the
|
// v2.0.0: reverse proxy /api/coder/* to boocoder (HTTP + WS). CoderPane
|
||||||
// SPA's HTTP requests going through a single origin (avoids CORS). WS for
|
// connects WS through /api/coder/ws/sessions/:id on the same origin.
|
||||||
// the coder pane connects directly to boocoder:9502 from the browser (same
|
|
||||||
// Tailscale network — no CORS issue for WebSocket upgrade requests).
|
|
||||||
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
||||||
app.all('/api/coder/*', async (req, reply) => {
|
registerCoderProxy(app, BOOCODER_ORIGIN);
|
||||||
const targetPath = req.url.replace('/api/coder', '/api');
|
|
||||||
const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`;
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
|
|
||||||
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(targetUrl, {
|
|
||||||
method: req.method as string,
|
|
||||||
headers,
|
|
||||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
|
||||||
});
|
|
||||||
reply.code(res.status);
|
|
||||||
for (const [key, value] of res.headers) {
|
|
||||||
if (key === 'transfer-encoding') continue;
|
|
||||||
reply.header(key, value);
|
|
||||||
}
|
|
||||||
const body = await res.text();
|
|
||||||
return reply.send(body);
|
|
||||||
} catch (err) {
|
|
||||||
app.log.error({ err, targetUrl }, 'coder proxy error');
|
|
||||||
reply.code(502).send({ error: 'boocoder backend unavailable' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
|
|||||||
91
apps/server/src/routes/coder-proxy.ts
Normal file
91
apps/server/src/routes/coder-proxy.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
function boocoderWsUrl(origin: string, path: string): string {
|
||||||
|
const u = new URL(origin);
|
||||||
|
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
u.pathname = path;
|
||||||
|
u.search = '';
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse-proxy BooCoder HTTP + WebSocket through BooChat's single origin.
|
||||||
|
* WS must be registered before the HTTP catch-all — fetch() cannot upgrade.
|
||||||
|
*/
|
||||||
|
export function registerCoderProxy(app: FastifyInstance, boocoderOrigin: string): void {
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/coder/ws/sessions/:sessionId',
|
||||||
|
{ websocket: true },
|
||||||
|
(clientSocket, req) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const target = boocoderWsUrl(boocoderOrigin, `/api/ws/sessions/${sessionId}`);
|
||||||
|
const upstream = new WebSocket(target);
|
||||||
|
|
||||||
|
upstream.on('open', () => {
|
||||||
|
app.log.debug({ sessionId }, 'coder ws proxy: upstream connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.on('message', (data, isBinary) => {
|
||||||
|
if (clientSocket.readyState !== clientSocket.OPEN) return;
|
||||||
|
clientSocket.send(data, { binary: isBinary });
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.on('close', (code, reason) => {
|
||||||
|
if (clientSocket.readyState === clientSocket.OPEN) {
|
||||||
|
clientSocket.close(code, reason.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.on('error', (err) => {
|
||||||
|
app.log.warn({ err, sessionId, target }, 'coder ws proxy: upstream error');
|
||||||
|
if (clientSocket.readyState === clientSocket.OPEN) {
|
||||||
|
clientSocket.close(1011, 'upstream error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on('message', (data, isBinary) => {
|
||||||
|
if (upstream.readyState !== WebSocket.OPEN) return;
|
||||||
|
upstream.send(data, { binary: isBinary });
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on('close', () => {
|
||||||
|
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||||
|
upstream.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on('error', () => {
|
||||||
|
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||||
|
upstream.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.all('/api/coder/*', async (req, reply) => {
|
||||||
|
const targetPath = req.url.replace('/api/coder', '/api');
|
||||||
|
const targetUrl = `${boocoderOrigin}${targetPath}`;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
|
||||||
|
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(targetUrl, {
|
||||||
|
method: req.method as string,
|
||||||
|
headers,
|
||||||
|
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||||
|
});
|
||||||
|
reply.code(res.status);
|
||||||
|
for (const [key, value] of res.headers) {
|
||||||
|
if (key === 'transfer-encoding') continue;
|
||||||
|
reply.header(key, value);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
return reply.send(body);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err, targetUrl }, 'coder proxy error');
|
||||||
|
reply.code(502).send({ error: 'boocoder backend unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ const WorkspacePaneZ = z.object({
|
|||||||
kind: z.enum([
|
kind: z.enum([
|
||||||
'chat',
|
'chat',
|
||||||
'terminal',
|
'terminal',
|
||||||
'agent',
|
'coder',
|
||||||
|
'agent', // legacy alias — normalized to coder on write
|
||||||
'empty',
|
'empty',
|
||||||
'settings',
|
'settings',
|
||||||
'markdown_artifact',
|
'markdown_artifact',
|
||||||
@@ -307,9 +308,12 @@ export function registerSessionRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
|
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
||||||
|
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||||
|
);
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
|
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Chat } from '../types/api.js';
|
import type { Chat } from '../types/api.js';
|
||||||
import { getSkillBody, listSkills } from '../services/skills.js';
|
import { getSkillBody, listSkills } from '../services/skills.js';
|
||||||
|
import {
|
||||||
|
buildSkillInvokeSyntheticFrames,
|
||||||
|
DEFAULT_SKILL_USER_MESSAGE,
|
||||||
|
runSkillInvokeTransaction,
|
||||||
|
} from '../services/skill-invoke.js';
|
||||||
|
|
||||||
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
||||||
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
||||||
@@ -35,8 +39,6 @@ const SkillInvokeBody = z.object({
|
|||||||
user_message: z.string().max(64_000).nullable().optional(),
|
user_message: z.string().max(64_000).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
|
|
||||||
|
|
||||||
export function registerSkillsRoutes(
|
export function registerSkillsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -62,7 +64,9 @@ export function registerSkillsRoutes(
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const { skill_name } = parsed.data;
|
const { skill_name } = parsed.data;
|
||||||
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
|
const userText = parsed.data.user_message?.trim()
|
||||||
|
? parsed.data.user_message
|
||||||
|
: DEFAULT_SKILL_USER_MESSAGE;
|
||||||
|
|
||||||
const chatRows = await sql<Chat[]>`
|
const chatRows = await sql<Chat[]>`
|
||||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
@@ -80,87 +84,20 @@ export function registerSkillsRoutes(
|
|||||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallId = randomUUID();
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||||
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
|
sessionId,
|
||||||
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
|
chatId: chat.id,
|
||||||
|
skillName: skill_name,
|
||||||
const result = await sql.begin(async (tx) => {
|
skillBody: body,
|
||||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
userText,
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
||||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
|
||||||
// content, so one part at seq 0.
|
|
||||||
await tx`
|
|
||||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
|
||||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
|
||||||
id: toolCallId,
|
|
||||||
name: 'skill_use',
|
|
||||||
args: { name: skill_name },
|
|
||||||
} as never)})
|
|
||||||
`;
|
|
||||||
const [toolMsg] = await tx<{ id: string }[]>`
|
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
||||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
|
||||||
await tx`
|
|
||||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
|
||||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
|
||||||
`;
|
|
||||||
const [userMsg] = await tx<{ id: string }[]>`
|
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
||||||
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
||||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
|
||||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
|
||||||
return {
|
|
||||||
synth_assistant_id: synthAssistant!.id,
|
|
||||||
tool_message_id: toolMsg!.id,
|
|
||||||
user_message_id: userMsg!.id,
|
|
||||||
assistant_message_id: assistantMsg!.id,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Synthetic frames so useSessionStream's reducer reflects the new
|
// Synthetic frames so useSessionStream's reducer reflects the new
|
||||||
// history without a refetch. Frame shapes match the streaming-inference
|
// history without a refetch. Frame shapes match the streaming-inference
|
||||||
// protocol (see services/inference.ts InferenceFrame).
|
// protocol (see services/inference.ts InferenceFrame).
|
||||||
handlers.publishSessionFrame(sessionId, {
|
for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) {
|
||||||
type: 'message_started',
|
handlers.publishSessionFrame(sessionId, frame);
|
||||||
message_id: result.synth_assistant_id,
|
}
|
||||||
chat_id: chat.id,
|
|
||||||
role: 'assistant',
|
|
||||||
});
|
|
||||||
handlers.publishSessionFrame(sessionId, {
|
|
||||||
type: 'tool_call',
|
|
||||||
message_id: result.synth_assistant_id,
|
|
||||||
chat_id: chat.id,
|
|
||||||
tool_call: toolCalls[0]!,
|
|
||||||
});
|
|
||||||
handlers.publishSessionFrame(sessionId, {
|
|
||||||
type: 'message_complete',
|
|
||||||
message_id: result.synth_assistant_id,
|
|
||||||
chat_id: chat.id,
|
|
||||||
});
|
|
||||||
// The tool_result frame's reducer branch creates the tool-role message
|
|
||||||
// in-place when it doesn't already exist — no separate message_started
|
|
||||||
// is needed for the tool side.
|
|
||||||
handlers.publishSessionFrame(sessionId, {
|
|
||||||
type: 'tool_result',
|
|
||||||
tool_message_id: result.tool_message_id,
|
|
||||||
tool_call_id: toolCallId,
|
|
||||||
chat_id: chat.id,
|
|
||||||
output: body,
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
||||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
-- v1.11: anchored rolling compaction.
|
-- v1.11: anchored rolling compaction.
|
||||||
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||||
@@ -366,3 +367,39 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT F
|
|||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
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);
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||||
|
|
||||||
|
-- tasks table (provider dispatch, arena)
|
||||||
|
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,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
mode_id TEXT,
|
||||||
|
thinking_option_id TEXT,
|
||||||
|
feature_values JSONB,
|
||||||
|
execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fix tasks FK to cascade on session delete (existing tables without CASCADE)
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'tasks_session_id_fkey'
|
||||||
|
AND confdeltype != 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT tasks_session_id_fkey;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|||||||
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseAgentsMd, matchToolGlob } from '../agents.js';
|
||||||
|
import { toolJsonSchemas } from '../tools.js';
|
||||||
|
|
||||||
|
describe('agent tool allowlist', () => {
|
||||||
|
const plannerMd = `# Agents
|
||||||
|
|
||||||
|
## Planner
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files]
|
||||||
|
description: Read-only planner
|
||||||
|
---
|
||||||
|
You plan.
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('parses an agent with a restricted tool allowlist', () => {
|
||||||
|
const { agents, errors } = parseAgentsMd(plannerMd);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(planner.name).toBe('Planner');
|
||||||
|
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, planner.tools),
|
||||||
|
);
|
||||||
|
const filteredNames = filtered.map((t) => t.function.name);
|
||||||
|
expect(filteredNames).toContain('view_file');
|
||||||
|
expect(filteredNames).toContain('grep');
|
||||||
|
expect(filteredNames).not.toContain('edit_file');
|
||||||
|
expect(filteredNames).not.toContain('web_search');
|
||||||
|
expect(filteredNames).not.toContain('get_codebase_overview');
|
||||||
|
expect(filtered).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: allows tool call in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null/absent tools field defaults to all tools (no regression)', () => {
|
||||||
|
const noToolsMd = `# Agents
|
||||||
|
|
||||||
|
## Default
|
||||||
|
---
|
||||||
|
temperature: 0.7
|
||||||
|
description: Uses all tools
|
||||||
|
---
|
||||||
|
Default agent.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(noToolsMd);
|
||||||
|
const agent = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, agent.tools),
|
||||||
|
);
|
||||||
|
expect(filtered.length).toBe(allSchemas.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
|
||||||
|
const builderMd = `# Agents
|
||||||
|
|
||||||
|
## Builder
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||||
|
description: Read and write tools
|
||||||
|
---
|
||||||
|
You build.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(builderMd);
|
||||||
|
const builder = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', builder.tools)).toBe(true);
|
||||||
|
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
|
||||||
|
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
|
||||||
|
expect(builder.tools).not.toContain('edit_file');
|
||||||
|
expect(builder.tools).not.toContain('create_file');
|
||||||
|
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
|
||||||
|
const allowlist = ['view_file', 'grep', 'list_dir'];
|
||||||
|
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isAgentRegistryMarkdown, parseAgentsMd } from '../agents.js';
|
||||||
|
|
||||||
|
describe('isAgentRegistryMarkdown', () => {
|
||||||
|
it('rejects Cursor navigation AGENTS.md at repo root', () => {
|
||||||
|
expect(
|
||||||
|
isAgentRegistryMarkdown('# Agent navigation\n\n## Doc map\n'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the global data/AGENTS.md registry shape', () => {
|
||||||
|
expect(isAgentRegistryMarkdown('# Agents\n\n## Code Reviewer\n---\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAgentsMd', () => {
|
||||||
|
it('does not emit errors for navigation sections when file is skipped upstream', () => {
|
||||||
|
// When isAgentRegistryMarkdown returns false, getAgentsForProject never calls this.
|
||||||
|
// Sanity: a nav-shaped file would produce six "missing fence" errors if parsed.
|
||||||
|
const nav = `# Agent navigation
|
||||||
|
|
||||||
|
## Doc map
|
||||||
|
| Need | Read |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
## Task routing
|
||||||
|
Start here
|
||||||
|
`;
|
||||||
|
const r = parseAgentsMd(nav);
|
||||||
|
expect(r.agents).toHaveLength(0);
|
||||||
|
expect(r.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { htmlToMarkdown } from '../web/html-to-md.js';
|
||||||
|
|
||||||
|
describe('htmlToMarkdown', () => {
|
||||||
|
it('converts h1 heading', () => {
|
||||||
|
expect(htmlToMarkdown('<h1>Title</h1>')).toBe('# Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts h1 through h6', () => {
|
||||||
|
const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('# One');
|
||||||
|
expect(md).toContain('## Two');
|
||||||
|
expect(md).toContain('### Three');
|
||||||
|
expect(md).toContain('#### Four');
|
||||||
|
expect(md).toContain('##### Five');
|
||||||
|
expect(md).toContain('###### Six');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor with href', () => {
|
||||||
|
expect(htmlToMarkdown('<a href="https://example.com">click here</a>'))
|
||||||
|
.toBe('[click here](https://example.com)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor without href to plain text', () => {
|
||||||
|
expect(htmlToMarkdown('<a>just text</a>')).toBe('just text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts bold and italic', () => {
|
||||||
|
expect(htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||||
|
expect(htmlToMarkdown('<b>bold</b>')).toBe('**bold**');
|
||||||
|
expect(htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||||
|
expect(htmlToMarkdown('<i>italic</i>')).toBe('*italic*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles combined bold+italic', () => {
|
||||||
|
const md = htmlToMarkdown('<strong><em>bold italic</em></strong>');
|
||||||
|
expect(md).toBe('***bold italic***');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts unordered list', () => {
|
||||||
|
const html = '<ul><li>one</li><li>two</li><li>three</li></ul>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('* one');
|
||||||
|
expect(md).toContain('* two');
|
||||||
|
expect(md).toContain('* three');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts ordered list', () => {
|
||||||
|
const html = '<ol><li>first</li><li>second</li></ol>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('1. first');
|
||||||
|
expect(md).toContain('2. second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested lists', () => {
|
||||||
|
const html = '<ul><li>outer<ul><li>inner</li></ul></li></ul>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('* outer');
|
||||||
|
expect(md).toContain(' * inner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 3-column GFM table with header', () => {
|
||||||
|
const html = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Alice</td><td>30</td><td>NYC</td></tr>
|
||||||
|
<tr><td>Bob</td><td>25</td><td>LA</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('| Name | Age | City |');
|
||||||
|
expect(md).toContain('| --- | --- | --- |');
|
||||||
|
expect(md).toContain('| Alice | 30 | NYC |');
|
||||||
|
expect(md).toContain('| Bob | 25 | LA |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes pipe characters in table cells', () => {
|
||||||
|
const html = '<table><tr><th>A</th></tr><tr><td>x | y</td></tr></table>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('x \\| y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts blockquote', () => {
|
||||||
|
const html = '<blockquote><p>quoted text</p></blockquote>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('> quoted text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts multi-line blockquote', () => {
|
||||||
|
const html = '<blockquote><p>line one</p><p>line two</p></blockquote>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('> line one');
|
||||||
|
expect(md).toContain('> line two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts fenced code block', () => {
|
||||||
|
const html = '<pre><code>const x = 1;</code></pre>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('```\nconst x = 1;\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves language hint from code class', () => {
|
||||||
|
const html = '<pre><code class="language-py">print("hello")</code></pre>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('```py\nprint("hello")\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts inline code', () => {
|
||||||
|
expect(htmlToMarkdown('use <code>npm install</code> to install'))
|
||||||
|
.toContain('`npm install`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes HTML entities', () => {
|
||||||
|
expect(htmlToMarkdown('& < > "')).toBe('& < > "');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes numeric character references', () => {
|
||||||
|
expect(htmlToMarkdown(''')).toBe("'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes as space', () => {
|
||||||
|
const md = htmlToMarkdown('hello world');
|
||||||
|
expect(md).toMatch(/hello\s+world/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips script content', () => {
|
||||||
|
const html = '<p>before</p><script>alert("xss")</script><p>after</p>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).not.toContain('alert');
|
||||||
|
expect(md).toContain('before');
|
||||||
|
expect(md).toContain('after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips style content', () => {
|
||||||
|
const html = '<p>text</p><style>body { color: red }</style>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).not.toContain('color');
|
||||||
|
expect(md).toContain('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on malformed HTML', () => {
|
||||||
|
expect(() => htmlToMarkdown('<p>unclosed <b>bold <i>italic')).not.toThrow();
|
||||||
|
const md = htmlToMarkdown('<p>unclosed <b>bold <i>italic');
|
||||||
|
expect(md).toContain('bold');
|
||||||
|
expect(md).toContain('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(htmlToMarkdown('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for whitespace-only input', () => {
|
||||||
|
expect(htmlToMarkdown(' \n\n ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts hr to horizontal rule', () => {
|
||||||
|
const md = htmlToMarkdown('<p>above</p><hr><p>below</p>');
|
||||||
|
expect(md).toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts br to newline', () => {
|
||||||
|
const md = htmlToMarkdown('line one<br>line two');
|
||||||
|
expect(md).toContain('line one\nline two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ol with start attribute', () => {
|
||||||
|
const html = '<ol start="5"><li>five</li><li>six</li></ol>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('5. five');
|
||||||
|
expect(md).toContain('6. six');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses excessive blank lines', () => {
|
||||||
|
const html = '<p>one</p><p></p><p></p><p></p><p>two</p>';
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
const blankRuns = md.match(/\n{3,}/g);
|
||||||
|
expect(blankRuns).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Golden test: small Hacker News-style snippet
|
||||||
|
it('golden: HN-style snippet produces structured markdown', () => {
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<head><title>Test Page</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p>This is a <strong>test</strong> page with <a href="https://example.com">a link</a>.</p>
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Fast</li>
|
||||||
|
<li>Reliable</li>
|
||||||
|
<li>Secure</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Data</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Uptime</td><td>99.9%</td></tr>
|
||||||
|
<tr><td>Latency</td><td>42ms</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<blockquote><p>This tool is amazing.</p></blockquote>
|
||||||
|
<pre><code class="language-js">console.log("hello");</code></pre>
|
||||||
|
<script>evil();</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
const md = htmlToMarkdown(html);
|
||||||
|
expect(md).toContain('# Welcome');
|
||||||
|
expect(md).toContain('**test**');
|
||||||
|
expect(md).toContain('[a link](https://example.com)');
|
||||||
|
expect(md).toContain('## Features');
|
||||||
|
expect(md).toContain('* Fast');
|
||||||
|
expect(md).toContain('| Metric | Value |');
|
||||||
|
expect(md).toContain('| --- | --- |');
|
||||||
|
expect(md).toContain('| Uptime | 99.9% |');
|
||||||
|
expect(md).toContain('> This tool is amazing.');
|
||||||
|
expect(md).toContain('```js\nconsole.log("hello");\n```');
|
||||||
|
expect(md).not.toContain('evil');
|
||||||
|
expect(md).not.toContain('<title>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,6 +226,76 @@ describe('buildMessagesPayload', async () => {
|
|||||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips assistant tool_calls when matching tool results are missing', async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
id: 'call_orphan',
|
||||||
|
name: 'grep',
|
||||||
|
args: { pattern: 'foo' },
|
||||||
|
};
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'search'),
|
||||||
|
makeMessage('assistant', 'partial answer', { tool_calls: [toolCall] }),
|
||||||
|
makeMessage('assistant', 'final answer'),
|
||||||
|
];
|
||||||
|
const result = await buildMessagesPayload(session, project, history);
|
||||||
|
// tool_calls stripped from the orphan turn; text content kept.
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result[1]).toMatchObject({ role: 'user', content: 'search' });
|
||||||
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'partial answer' });
|
||||||
|
expect(result[2]!.tool_calls).toBeUndefined();
|
||||||
|
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops tool-call-only assistant rows when tool results never arrived', async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
id: 'call_orphan_only',
|
||||||
|
name: 'grep',
|
||||||
|
args: { pattern: 'foo' },
|
||||||
|
};
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'search'),
|
||||||
|
makeMessage('assistant', '', { tool_calls: [toolCall] }),
|
||||||
|
makeMessage('assistant', 'final answer'),
|
||||||
|
];
|
||||||
|
const result = await buildMessagesPayload(session, project, history);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips stray tool rows when the owning assistant tool_calls were stripped', async () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const toolCallA: ToolCall = {
|
||||||
|
id: 'call_a',
|
||||||
|
name: 'grep',
|
||||||
|
args: { pattern: 'foo' },
|
||||||
|
};
|
||||||
|
const toolCallB: ToolCall = {
|
||||||
|
id: 'call_b',
|
||||||
|
name: 'read',
|
||||||
|
args: { path: 'x' },
|
||||||
|
};
|
||||||
|
const toolResult: ToolResult = {
|
||||||
|
tool_call_id: 'call_a',
|
||||||
|
output: 'match',
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'search'),
|
||||||
|
makeMessage('assistant', '', { tool_calls: [toolCallA, toolCallB] }),
|
||||||
|
makeMessage('tool', '', { tool_results: toolResult }),
|
||||||
|
makeMessage('assistant', 'final answer'),
|
||||||
|
];
|
||||||
|
const result = await buildMessagesPayload(session, project, history);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
||||||
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||||
|
});
|
||||||
|
|
||||||
it('skips tool rows with no tool_results', async () => {
|
it('skips tool rows with no tool_results', async () => {
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
const project = makeProject();
|
const project = makeProject();
|
||||||
|
|||||||
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
validateExtraArgs,
|
||||||
|
isManagedFlag,
|
||||||
|
stripShadowingFlags,
|
||||||
|
} from '../inference/llama-args-validator.js';
|
||||||
|
import { parseAgentsMd } from '../agents.js';
|
||||||
|
|
||||||
|
describe('validateExtraArgs', () => {
|
||||||
|
describe('deny list — each alias rejected', () => {
|
||||||
|
const denied = [
|
||||||
|
'-m', '--model',
|
||||||
|
'-mu', '--model-url',
|
||||||
|
'-dr', '--docker-repo',
|
||||||
|
'-hf', '-hfr', '--hf-repo',
|
||||||
|
'-hff', '--hf-file',
|
||||||
|
'-hfv', '-hfrv', '--hf-repo-v',
|
||||||
|
'-hffv', '--hf-file-v',
|
||||||
|
'-hft', '--hf-token',
|
||||||
|
'-mm', '--mmproj',
|
||||||
|
'-mmu', '--mmproj-url',
|
||||||
|
'--host', '--port', '--path', '--api-prefix', '--reuse-port',
|
||||||
|
'--api-key', '--api-key-file',
|
||||||
|
'--ssl-key-file', '--ssl-cert-file',
|
||||||
|
'--webui', '--no-webui', '--ui', '--no-ui',
|
||||||
|
'--ui-config', '--ui-config-file',
|
||||||
|
'--ui-mcp-proxy', '--no-ui-mcp-proxy',
|
||||||
|
'--models-dir', '--models-preset', '--models-max',
|
||||||
|
'--models-autoload', '--no-models-autoload',
|
||||||
|
];
|
||||||
|
for (const flag of denied) {
|
||||||
|
it(`rejects ${flag}`, () => {
|
||||||
|
expect(() => validateExtraArgs([flag])).toThrow(/managed/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safe flags accepted', () => {
|
||||||
|
const safe = [
|
||||||
|
'-c', '--ctx-size', '-ngl', '--gpu-layers',
|
||||||
|
'--top-k', '--cache-type-k', '--jinja', '--no-jinja',
|
||||||
|
'--spec-draft-n-max', '-fa', '--flash-attn',
|
||||||
|
'-t', '--threads', '-np', '--parallel',
|
||||||
|
];
|
||||||
|
for (const flag of safe) {
|
||||||
|
it(`accepts ${flag}`, () => {
|
||||||
|
expect(() => validateExtraArgs([flag])).not.toThrow();
|
||||||
|
expect(validateExtraArgs([flag])).toEqual([flag]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles --flag=value shape (denies the flag part)', () => {
|
||||||
|
expect(() => validateExtraArgs(['--model=evil.gguf'])).toThrow(/managed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles --flag=value shape (accepts safe flag)', () => {
|
||||||
|
expect(validateExtraArgs(['--ctx-size=4096'])).toEqual(['--ctx-size=4096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for undefined input', () => {
|
||||||
|
expect(validateExtraArgs(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(validateExtraArgs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats negative numbers as values, not flags', () => {
|
||||||
|
expect(validateExtraArgs(['--seed', '-1'])).toEqual(['--seed', '-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isManagedFlag', () => {
|
||||||
|
it('returns true for denied flags', () => {
|
||||||
|
expect(isManagedFlag('--model')).toBe(true);
|
||||||
|
expect(isManagedFlag('-m')).toBe(true);
|
||||||
|
expect(isManagedFlag('--api-key')).toBe(true);
|
||||||
|
expect(isManagedFlag('--port')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for safe flags', () => {
|
||||||
|
expect(isManagedFlag('-c')).toBe(false);
|
||||||
|
expect(isManagedFlag('--ctx-size')).toBe(false);
|
||||||
|
expect(isManagedFlag('--top-k')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripShadowingFlags', () => {
|
||||||
|
it('strips auto -c when user supplies -c', () => {
|
||||||
|
const result = stripShadowingFlags(['-c', '4096', '--top-k', '40']);
|
||||||
|
expect(result).toEqual(['--top-k', '40']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains both when no overlap', () => {
|
||||||
|
const result = stripShadowingFlags(['--top-k', '40', '--top-p', '0.95']);
|
||||||
|
expect(result).toEqual(['--top-k', '40', '--top-p', '0.95']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips --ctx-size=value form', () => {
|
||||||
|
const result = stripShadowingFlags(['--ctx-size=4096']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips boolean --jinja flag (no value consumed)', () => {
|
||||||
|
const result = stripShadowingFlags(['--jinja', '--top-k', '40']);
|
||||||
|
expect(result).toEqual(['--top-k', '40']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects stripContext=false to keep context flags', () => {
|
||||||
|
const result = stripShadowingFlags(['-c', '4096'], { stripContext: false });
|
||||||
|
expect(result).toEqual(['-c', '4096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips cache flags by default', () => {
|
||||||
|
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips spec flags by default', () => {
|
||||||
|
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AGENTS.md frontmatter validation', () => {
|
||||||
|
it('rejects agent with managed flag in llama_extra_args', () => {
|
||||||
|
const md = `## Evil Agent
|
||||||
|
---
|
||||||
|
llama_extra_args: ["--model", "evil.gguf"]
|
||||||
|
---
|
||||||
|
You are evil.`;
|
||||||
|
const { agents, errors } = parseAgentsMd(md);
|
||||||
|
expect(agents).toHaveLength(0);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.reason).toContain('managed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts agent with safe llama_extra_args', () => {
|
||||||
|
const md = `## Good Agent
|
||||||
|
---
|
||||||
|
llama_extra_args: ["--top-k", "20"]
|
||||||
|
---
|
||||||
|
You are good.`;
|
||||||
|
const { agents, errors } = parseAgentsMd(md);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]!.llama_extra_args).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent without llama_extra_args has null field', () => {
|
||||||
|
const md = `## Simple Agent
|
||||||
|
---
|
||||||
|
temperature: 0.5
|
||||||
|
---
|
||||||
|
You are simple.`;
|
||||||
|
const { agents } = parseAgentsMd(md);
|
||||||
|
expect(agents[0]!.llama_extra_args).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveRoute, upstreamModel } from '../inference/provider.js';
|
||||||
|
|
||||||
|
describe('resolveRoute', () => {
|
||||||
|
it('routes to swap when agent is null', () => {
|
||||||
|
expect(resolveRoute(null)).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has no llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: null })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has empty llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: [] })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to sidecar when agent has llama_extra_args', () => {
|
||||||
|
const result = resolveRoute({ llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(result.route).toBe('sidecar');
|
||||||
|
expect(result.flags).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upstreamModel', () => {
|
||||||
|
const swapConfig = { LLAMA_SWAP_URL: 'http://localhost:8401' };
|
||||||
|
const fullConfig = {
|
||||||
|
LLAMA_SWAP_URL: 'http://localhost:8401',
|
||||||
|
LLAMA_SIDECAR_URL: 'http://localhost:8402',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns a model for swap route (no agent)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for swap route (agent without extra args)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: null });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for sidecar route', () => {
|
||||||
|
const model = upstreamModel(fullConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when sidecar route requested but URL missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
upstreamModel(swapConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] }),
|
||||||
|
).toThrow(/LLAMA_SIDECAR_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap for empty llama_extra_args array', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: [] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
545
apps/server/src/services/__tests__/tool-call-parser.test.ts
Normal file
545
apps/server/src/services/__tests__/tool-call-parser.test.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseXmlToolCall,
|
||||||
|
parseInvokeToolCall,
|
||||||
|
partialXmlOpenerStart,
|
||||||
|
extractToolCallBlocks,
|
||||||
|
parseToolCallsFromText,
|
||||||
|
stripToolMarkup,
|
||||||
|
hasToolSignal,
|
||||||
|
XML_TOOL_OPEN,
|
||||||
|
XML_TOOL_CLOSE,
|
||||||
|
INVOKE_TOOL_OPEN,
|
||||||
|
INVOKE_TOOL_CLOSE,
|
||||||
|
TOOL_XML_SIGNALS,
|
||||||
|
BUDGET_EXHAUSTED_NUDGE,
|
||||||
|
DUPLICATE_CALL_NUDGE,
|
||||||
|
TOOL_ERROR_NUDGE,
|
||||||
|
TOOL_ERROR_PREFIXES,
|
||||||
|
} from '../inference/tool-call-parser.js';
|
||||||
|
|
||||||
|
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||||
|
it('parses a well-formed single-parameter call', () => {
|
||||||
|
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multi-parameter call', () => {
|
||||||
|
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toEqual({
|
||||||
|
name: 'grep',
|
||||||
|
args: { pattern: 'foo', path: 'src/' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON-parses numeric parameter values', () => {
|
||||||
|
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
|
||||||
|
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
|
||||||
|
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when function name is missing', () => {
|
||||||
|
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
|
||||||
|
expect(parseXmlToolCall(block)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||||
|
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
||||||
|
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a multi-parameter call (spec case 2)', () => {
|
||||||
|
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'grep',
|
||||||
|
args: { pattern: 'foo', path: 'src/' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||||
|
const block = `<invoke
|
||||||
|
name="view_file"
|
||||||
|
>
|
||||||
|
<parameter
|
||||||
|
name="path"
|
||||||
|
>/tmp/foo</parameter>
|
||||||
|
</invoke>`;
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
||||||
|
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'read_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports single-quoted attribute values', () => {
|
||||||
|
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON-parses numeric parameter values', () => {
|
||||||
|
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates spaces around = inside name attribute', () => {
|
||||||
|
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toEqual({
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: '/tmp/foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when name attribute is missing', () => {
|
||||||
|
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when name attribute is empty', () => {
|
||||||
|
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
expect(parseInvokeToolCall(block)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports the expected delimiters', () => {
|
||||||
|
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||||
|
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||||
|
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||||
|
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
||||||
|
it('returns -1 when the buffer is empty', () => {
|
||||||
|
expect(partialXmlOpenerStart('')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when the buffer has no openers', () => {
|
||||||
|
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the index of a complete <tool_call> opener (existing)', () => {
|
||||||
|
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
|
||||||
|
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a partial <tool_ prefix at end of buffer', () => {
|
||||||
|
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
|
||||||
|
expect(partialXmlOpenerStart('text <invo')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a bare < at end of buffer', () => {
|
||||||
|
expect(partialXmlOpenerStart('text <')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when < is followed by non-opener text', () => {
|
||||||
|
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the earliest opener when both flavors are present', () => {
|
||||||
|
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
|
||||||
|
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||||
|
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||||
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||||
|
expect(result.flushed).toBe('');
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
||||||
|
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||||
|
const result = extractToolCallBlocks(firstChunk);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toBe('');
|
||||||
|
expect(result.remaining).toBe(firstChunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => {
|
||||||
|
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||||
|
const r1 = extractToolCallBlocks(firstChunk);
|
||||||
|
const combined = r1.remaining + '</invoke>';
|
||||||
|
const r2 = extractToolCallBlocks(combined);
|
||||||
|
expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||||
|
expect(r2.flushed).toBe('');
|
||||||
|
expect(r2.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
||||||
|
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||||
|
expect(result.flushed).toBe('I will read the file.\n\nThanks.');
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
||||||
|
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||||
|
expect(result.flushed).toBe('');
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => {
|
||||||
|
const input =
|
||||||
|
'<invoke name="view_file"><parameter name="path">/a</parameter></invoke>' +
|
||||||
|
' middle ' +
|
||||||
|
'<tool_call><function=grep><parameter=pattern>foo</parameter></function></tool_call>';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([
|
||||||
|
{ name: 'view_file', args: { path: '/a' } },
|
||||||
|
{ name: 'grep', args: { pattern: 'foo' } },
|
||||||
|
]);
|
||||||
|
expect(result.flushed).toBe(' middle ');
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a malformed <invoke> block silently (matches existing <tool_call> behavior)', () => {
|
||||||
|
const input = 'prose <invoke><parameter name="path">/a</parameter></invoke> trailing';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toBe('prose trailing');
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => {
|
||||||
|
const input = '<invoke name="view_file"><parameter name="path">/a</parameter></invoke> next: <tool_';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/a' } }]);
|
||||||
|
expect(result.flushed).toBe(' next: ');
|
||||||
|
expect(result.remaining).toBe('<tool_');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes plain prose straight through when no markup is present', () => {
|
||||||
|
const input = 'just some text with a < character but no opener';
|
||||||
|
const result = extractToolCallBlocks(input);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toBe(input);
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeholder arg rejection (qwen3.6 answer-then-spurious-tools)', () => {
|
||||||
|
it('rejects <invoke> with path "..." — 0 calls, block in flushed', () => {
|
||||||
|
const block = '<invoke name="view_file"><parameter name="path">...</parameter></invoke>';
|
||||||
|
const result = extractToolCallBlocks(`Answer text.\n${block}`);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toContain('Answer text.');
|
||||||
|
expect(result.flushed).toContain(block);
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects <invoke> with empty path — 0 calls, block in flushed', () => {
|
||||||
|
const block = '<invoke name="view_file"><parameter name="path"></parameter></invoke>';
|
||||||
|
const result = extractToolCallBlocks(block);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toBe(block);
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects <invoke> with path "<path>" — 0 calls', () => {
|
||||||
|
const block = '<invoke name="view_file"><parameter name="path"><path></parameter></invoke>';
|
||||||
|
const result = extractToolCallBlocks(block);
|
||||||
|
expect(result.calls).toEqual([]);
|
||||||
|
expect(result.flushed).toBe(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1 valid call and flushes placeholder block when mixed in same buffer', () => {
|
||||||
|
const valid =
|
||||||
|
'<invoke name="view_file"><parameter name="path">/opt/boocode/README.md</parameter></invoke>';
|
||||||
|
const placeholder =
|
||||||
|
'<invoke name="view_file"><parameter name="path">...</parameter></invoke>';
|
||||||
|
const result = extractToolCallBlocks(`${valid} tail ${placeholder}`);
|
||||||
|
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/opt/boocode/README.md' } }]);
|
||||||
|
expect(result.flushed).toContain('tail');
|
||||||
|
expect(result.flushed).toContain(placeholder);
|
||||||
|
expect(result.remaining).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── New tests: Unsloth-ported functions ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('hasToolSignal', () => {
|
||||||
|
it('returns true for <tool_call>', () => {
|
||||||
|
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for <function=', () => {
|
||||||
|
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for <invoke', () => {
|
||||||
|
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for near-miss <tool>', () => {
|
||||||
|
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for near-miss <function>', () => {
|
||||||
|
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for near-miss <tool_call_thing>', () => {
|
||||||
|
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for plain text', () => {
|
||||||
|
expect(hasToolSignal('just some text')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripToolMarkup', () => {
|
||||||
|
it('strips closed <tool_call> blocks', () => {
|
||||||
|
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
|
||||||
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips closed <function=...> blocks', () => {
|
||||||
|
const input = 'before <function=x><parameter=y>z</parameter></function> after';
|
||||||
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips closed <invoke> blocks', () => {
|
||||||
|
const input = 'before <invoke name="x"><parameter name="y">z</parameter></invoke> after';
|
||||||
|
expect(stripToolMarkup(input)).toBe('before after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves trailing unclosed block when final=false', () => {
|
||||||
|
const input = 'text <tool_call>{"name":"x"';
|
||||||
|
expect(stripToolMarkup(input)).toBe('text <tool_call>{"name":"x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <tool_call> when final=true', () => {
|
||||||
|
const input = 'text <tool_call>{"name":"x"';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <function= when final=true', () => {
|
||||||
|
const input = 'text <function=run_bash><parameter=command>ls';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing unclosed <invoke when final=true', () => {
|
||||||
|
const input = 'text <invoke name="x"><parameter name="y">val';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace when final=true', () => {
|
||||||
|
const input = ' text <tool_call>partial';
|
||||||
|
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips multiple closed blocks', () => {
|
||||||
|
const input = '<tool_call>a</tool_call> mid <tool_call>b</tool_call>';
|
||||||
|
expect(stripToolMarkup(input)).toBe(' mid ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseToolCallsFromText', () => {
|
||||||
|
describe('pattern 1: <tool_call>{json}</tool_call>', () => {
|
||||||
|
it('parses a well-formed JSON tool call', () => {
|
||||||
|
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.id).toBe('call_0');
|
||||||
|
expect(calls[0]!.type).toBe('function');
|
||||||
|
expect(calls[0]!.function.name).toBe('web_search');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles string arguments field', () => {
|
||||||
|
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls[0]!.function.arguments).toBe('already a string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles balanced braces inside JSON strings', () => {
|
||||||
|
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const parsed = JSON.parse(calls[0]!.function.arguments);
|
||||||
|
expect(parsed.q).toBe('} { extra ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects idOffset', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input, { idOffset: 5 });
|
||||||
|
expect(calls[0]!.id).toBe('call_5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple JSON tool calls', () => {
|
||||||
|
const input =
|
||||||
|
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
|
||||||
|
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(calls[0]!.id).toBe('call_0');
|
||||||
|
expect(calls[1]!.id).toBe('call_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed JSON', () => {
|
||||||
|
const input = '<tool_call>{not json}</tool_call>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing closing tag', () => {
|
||||||
|
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pattern 2: <function=name><parameter=key>value', () => {
|
||||||
|
it('parses a single-parameter function call', () => {
|
||||||
|
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-param fast path preserves embedded </parameter>', () => {
|
||||||
|
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-param: value of first stops at start of second', () => {
|
||||||
|
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const args = JSON.parse(calls[0]!.function.arguments);
|
||||||
|
expect(args.pattern).toBe('foo');
|
||||||
|
expect(args.path).toBe('src/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates missing closing tags', () => {
|
||||||
|
const input = '<function=view_file><parameter=path>/tmp/foo';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 1 found results', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
|
||||||
|
it('parses a single-parameter invoke call', () => {
|
||||||
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multi-parameter invoke call', () => {
|
||||||
|
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const args = JSON.parse(calls[0]!.function.arguments);
|
||||||
|
expect(args.pattern).toBe('foo');
|
||||||
|
expect(args.path).toBe('src/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 1 found results', () => {
|
||||||
|
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when pattern 2 found results', () => {
|
||||||
|
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates missing closing tags', () => {
|
||||||
|
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports single-quoted attributes', () => {
|
||||||
|
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||||
|
const calls = parseToolCallsFromText(input);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.function.name).toBe('view_file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<function=');
|
||||||
|
expect(TOOL_XML_SIGNALS).toContain('<invoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nudge constants are non-empty strings', () => {
|
||||||
|
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
|
||||||
|
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
|
||||||
|
expect(TOOL_ERROR_PREFIXES).toContain('Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
levenshtein,
|
||||||
|
suggestToolName,
|
||||||
|
formatUnknownToolError,
|
||||||
|
} from '../inference/tool-suggestions.js';
|
||||||
|
|
||||||
|
describe('levenshtein', () => {
|
||||||
|
it('returns 0 for identical strings', () => {
|
||||||
|
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the length when one string is empty', () => {
|
||||||
|
expect(levenshtein('', 'view_file')).toBe(9);
|
||||||
|
expect(levenshtein('view_file', '')).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a small distance for a single-character substitution', () => {
|
||||||
|
expect(levenshtein('cat', 'bat')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a known case: read_file → view_file is 4', () => {
|
||||||
|
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestToolName (v1.13.16)', () => {
|
||||||
|
const tools = [
|
||||||
|
'view_file',
|
||||||
|
'list_dir',
|
||||||
|
'grep',
|
||||||
|
'find_files',
|
||||||
|
'view_truncated_output',
|
||||||
|
'ask_user_input',
|
||||||
|
'web_search',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('suggests the closest match when distance is small', () => {
|
||||||
|
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests via substring match when distance alone would miss', () => {
|
||||||
|
expect(suggestToolName('file', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing is close', () => {
|
||||||
|
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive in the distance check', () => {
|
||||||
|
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUnknownToolError (v1.13.16)', () => {
|
||||||
|
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
||||||
|
|
||||||
|
it('includes the wrong name and the available tools list', () => {
|
||||||
|
const msg = formatUnknownToolError('read_file', tools);
|
||||||
|
expect(msg).toContain("Tool 'read_file' not found");
|
||||||
|
expect(msg).toContain('Available tools:');
|
||||||
|
expect(msg).toContain('view_file');
|
||||||
|
expect(msg).toContain('find_files');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a suggestion when the drifted name is within threshold', () => {
|
||||||
|
const msg = formatUnknownToolError('view_files', tools);
|
||||||
|
expect(msg).toContain('Did you mean: view_file?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the suggestion clause when no tool is close enough', () => {
|
||||||
|
const msg = formatUnknownToolError('zzzzzzz', tools);
|
||||||
|
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
||||||
|
expect(msg).toContain('Available tools:');
|
||||||
|
expect(msg).not.toContain('Did you mean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
||||||
|
const msg = formatUnknownToolError('read_file', tools);
|
||||||
|
expect(msg).not.toContain('Did you mean');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
|
|
||||||
// <invoke> parser, the partial-opener detector for both flavors, the unified
|
|
||||||
// extraction helper, and the unknown-tool error formatter that downstream
|
|
||||||
// dispatch uses to give the model a recovery hint when it drifts to a
|
|
||||||
// Claude Code tool name like read_file instead of BooCode's view_file.
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
parseXmlToolCall,
|
|
||||||
parseInvokeToolCall,
|
|
||||||
partialXmlOpenerStart,
|
|
||||||
extractToolCallBlocks,
|
|
||||||
XML_TOOL_OPEN,
|
|
||||||
XML_TOOL_CLOSE,
|
|
||||||
INVOKE_TOOL_OPEN,
|
|
||||||
INVOKE_TOOL_CLOSE,
|
|
||||||
} from '../inference/xml-parser.js';
|
|
||||||
import {
|
|
||||||
levenshtein,
|
|
||||||
suggestToolName,
|
|
||||||
formatUnknownToolError,
|
|
||||||
} from '../inference/tool-suggestions.js';
|
|
||||||
|
|
||||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
|
||||||
it('parses a well-formed single-parameter call', () => {
|
|
||||||
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses multi-parameter call', () => {
|
|
||||||
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toEqual({
|
|
||||||
name: 'grep',
|
|
||||||
args: { pattern: 'foo', path: 'src/' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('JSON-parses numeric parameter values', () => {
|
|
||||||
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
|
|
||||||
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
|
|
||||||
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when function name is missing', () => {
|
|
||||||
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
|
|
||||||
expect(parseXmlToolCall(block)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
|
||||||
// Spec case 1
|
|
||||||
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
|
||||||
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 2
|
|
||||||
it('parses a multi-parameter call (spec case 2)', () => {
|
|
||||||
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'grep',
|
|
||||||
args: { pattern: 'foo', path: 'src/' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 3
|
|
||||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
|
||||||
const block = `<invoke
|
|
||||||
name="view_file"
|
|
||||||
>
|
|
||||||
<parameter
|
|
||||||
name="path"
|
|
||||||
>/tmp/foo</parameter>
|
|
||||||
</invoke>`;
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 4 (parser portion — the not-found enrichment is tested below)
|
|
||||||
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
|
||||||
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'read_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports single-quoted attribute values', () => {
|
|
||||||
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('JSON-parses numeric parameter values', () => {
|
|
||||||
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates spaces around = inside name attribute', () => {
|
|
||||||
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toEqual({
|
|
||||||
name: 'view_file',
|
|
||||||
args: { path: '/tmp/foo' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when name attribute is missing', () => {
|
|
||||||
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when name attribute is empty', () => {
|
|
||||||
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
|
|
||||||
expect(parseInvokeToolCall(block)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exports the expected delimiters', () => {
|
|
||||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
|
||||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
|
||||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
|
||||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
|
||||||
it('returns -1 when the buffer is empty', () => {
|
|
||||||
expect(partialXmlOpenerStart('')).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 when the buffer has no openers', () => {
|
|
||||||
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the index of a complete <tool_call> opener (existing)', () => {
|
|
||||||
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
|
|
||||||
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds a partial <tool_ prefix at end of buffer', () => {
|
|
||||||
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
|
|
||||||
expect(partialXmlOpenerStart('text <invo')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds a bare < at end of buffer', () => {
|
|
||||||
expect(partialXmlOpenerStart('text <')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 when < is followed by non-opener text', () => {
|
|
||||||
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the earliest opener when both flavors are present', () => {
|
|
||||||
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
|
|
||||||
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
|
||||||
// Spec case 1 (extraction-level)
|
|
||||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
|
||||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
|
||||||
expect(result.flushed).toBe('');
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 5: opener arrives in one chunk, closer in the next.
|
|
||||||
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
|
||||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
|
||||||
const result = extractToolCallBlocks(firstChunk);
|
|
||||||
expect(result.calls).toEqual([]);
|
|
||||||
expect(result.flushed).toBe('');
|
|
||||||
expect(result.remaining).toBe(firstChunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => {
|
|
||||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
|
||||||
const r1 = extractToolCallBlocks(firstChunk);
|
|
||||||
const combined = r1.remaining + '</invoke>';
|
|
||||||
const r2 = extractToolCallBlocks(combined);
|
|
||||||
expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
|
||||||
expect(r2.flushed).toBe('');
|
|
||||||
expect(r2.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 6: prose interleaving
|
|
||||||
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
|
||||||
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
|
||||||
expect(result.flushed).toBe('I will read the file.\n\nThanks.');
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec case 7 regression
|
|
||||||
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
|
||||||
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
|
||||||
expect(result.flushed).toBe('');
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => {
|
|
||||||
const input =
|
|
||||||
'<invoke name="view_file"><parameter name="path">/a</parameter></invoke>' +
|
|
||||||
' middle ' +
|
|
||||||
'<tool_call><function=grep><parameter=pattern>foo</parameter></function></tool_call>';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([
|
|
||||||
{ name: 'view_file', args: { path: '/a' } },
|
|
||||||
{ name: 'grep', args: { pattern: 'foo' } },
|
|
||||||
]);
|
|
||||||
expect(result.flushed).toBe(' middle ');
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops a malformed <invoke> block silently (matches existing <tool_call> behavior)', () => {
|
|
||||||
const input = 'prose <invoke><parameter name="path">/a</parameter></invoke> trailing';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([]);
|
|
||||||
expect(result.flushed).toBe('prose trailing');
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => {
|
|
||||||
const input = '<invoke name="view_file"><parameter name="path">/a</parameter></invoke> next: <tool_';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/a' } }]);
|
|
||||||
expect(result.flushed).toBe(' next: ');
|
|
||||||
expect(result.remaining).toBe('<tool_');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes plain prose straight through when no markup is present', () => {
|
|
||||||
const input = 'just some text with a < character but no opener';
|
|
||||||
const result = extractToolCallBlocks(input);
|
|
||||||
expect(result.calls).toEqual([]);
|
|
||||||
expect(result.flushed).toBe(input);
|
|
||||||
expect(result.remaining).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('levenshtein', () => {
|
|
||||||
it('returns 0 for identical strings', () => {
|
|
||||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the length when one string is empty', () => {
|
|
||||||
expect(levenshtein('', 'view_file')).toBe(9);
|
|
||||||
expect(levenshtein('view_file', '')).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes a small distance for a single-character substitution', () => {
|
|
||||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('computes a known case: read_file → view_file is 4', () => {
|
|
||||||
// r→v, e→i, a→e, d→w → 4 substitutions, same length
|
|
||||||
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('suggestToolName (v1.13.16)', () => {
|
|
||||||
const tools = [
|
|
||||||
'view_file',
|
|
||||||
'list_dir',
|
|
||||||
'grep',
|
|
||||||
'find_files',
|
|
||||||
'view_truncated_output',
|
|
||||||
'ask_user_input',
|
|
||||||
'web_search',
|
|
||||||
];
|
|
||||||
|
|
||||||
it('suggests the closest match when distance is small', () => {
|
|
||||||
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suggests via substring match when distance alone would miss', () => {
|
|
||||||
// 'file' is a substring of multiple tools; closest by distance wins.
|
|
||||||
expect(suggestToolName('file', tools)).toBe('view_file');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when nothing is close', () => {
|
|
||||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case-insensitive in the distance check', () => {
|
|
||||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatUnknownToolError (v1.13.16)', () => {
|
|
||||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
|
||||||
|
|
||||||
it('includes the wrong name and the available tools list', () => {
|
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
|
||||||
expect(msg).toContain("Tool 'read_file' not found");
|
|
||||||
expect(msg).toContain('Available tools:');
|
|
||||||
expect(msg).toContain('view_file');
|
|
||||||
expect(msg).toContain('find_files');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes a suggestion when the drifted name is within threshold', () => {
|
|
||||||
// distance(view_files, view_file) = 1 (one extra char)
|
|
||||||
const msg = formatUnknownToolError('view_files', tools);
|
|
||||||
expect(msg).toContain('Did you mean: view_file?');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the suggestion clause when no tool is close enough', () => {
|
|
||||||
const msg = formatUnknownToolError('zzzzzzz', tools);
|
|
||||||
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
|
||||||
expect(msg).toContain('Available tools:');
|
|
||||||
expect(msg).not.toContain('Did you mean');
|
|
||||||
});
|
|
||||||
|
|
||||||
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
|
|
||||||
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
|
|
||||||
// the spec's threshold (<=3) doesn't suggest view_file — the model still
|
|
||||||
// gets the available-tools list to pick from. This pins that behavior so a
|
|
||||||
// future loosening of the threshold is a deliberate choice.
|
|
||||||
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
|
||||||
const msg = formatUnknownToolError('read_file', tools);
|
|
||||||
expect(msg).not.toContain('Did you mean');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||||
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
||||||
|
import { validateExtraArgs } from './inference/llama-args-validator.js';
|
||||||
|
|
||||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||||
@@ -83,6 +84,10 @@ export function slugify(name: string): string {
|
|||||||
|
|
||||||
interface ParsedFrontmatter {
|
interface ParsedFrontmatter {
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
|
min_p?: number;
|
||||||
|
presence_penalty?: number;
|
||||||
tools?: string[];
|
tools?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -93,6 +98,7 @@ interface ParsedFrontmatter {
|
|||||||
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||||
// allowed" — the model responds text-only.
|
// allowed" — the model responds text-only.
|
||||||
steps?: number;
|
steps?: number;
|
||||||
|
llama_extra_args?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -132,6 +138,46 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
const n = Number(valueRaw);
|
const n = Number(valueRaw);
|
||||||
if (Number.isFinite(n)) data.temperature = n;
|
if (Number.isFinite(n)) data.temperature = n;
|
||||||
else errors.push(`temperature must be a number (got "${valueRaw}")`);
|
else errors.push(`temperature must be a number (got "${valueRaw}")`);
|
||||||
|
} else if (key === 'top_p') {
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
data.top_p = n;
|
||||||
|
if (n < 0 || n > 1) {
|
||||||
|
console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(`top_p must be a number (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
|
} else if (key === 'top_k') {
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isInteger(n)) {
|
||||||
|
data.top_k = n;
|
||||||
|
if (n < 0 || n > 200) {
|
||||||
|
console.warn(`agents: top_k ${n} out of range 0-200, ignoring (falling back to default)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(`top_k must be an integer (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
|
} else if (key === 'min_p') {
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
data.min_p = n;
|
||||||
|
if (n < 0 || n > 1) {
|
||||||
|
console.warn(`agents: min_p ${n} out of range 0-1, ignoring (falling back to default)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(`min_p must be a number (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
|
} else if (key === 'presence_penalty') {
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
data.presence_penalty = n;
|
||||||
|
if (n < -2 || n > 2) {
|
||||||
|
console.warn(`agents: presence_penalty ${n} out of range -2-2, ignoring (falling back to default)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(`presence_penalty must be a number (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
} else if (key === 'tools') {
|
} else if (key === 'tools') {
|
||||||
if (valueRaw === '') {
|
if (valueRaw === '') {
|
||||||
data.tools = [];
|
data.tools = [];
|
||||||
@@ -183,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||||
}
|
}
|
||||||
|
} else if (key === 'llama_extra_args') {
|
||||||
|
if (valueRaw === '') {
|
||||||
|
data.llama_extra_args = [];
|
||||||
|
// No arrayKey support — llama_extra_args uses inline list only.
|
||||||
|
} else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
|
||||||
|
const inner = valueRaw.slice(1, -1);
|
||||||
|
const parsed = inner
|
||||||
|
.split(',')
|
||||||
|
.map((s) => stripQuotes(s.trim()))
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
try {
|
||||||
|
validateExtraArgs(parsed);
|
||||||
|
data.llama_extra_args = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = valueRaw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => stripQuotes(s.trim()))
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
try {
|
||||||
|
validateExtraArgs(parsed);
|
||||||
|
data.llama_extra_args = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Unknown keys silently ignored — forward-compat.
|
// Unknown keys silently ignored — forward-compat.
|
||||||
}
|
}
|
||||||
@@ -276,10 +350,15 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
description: fm.description ?? '',
|
description: fm.description ?? '',
|
||||||
system_prompt: systemPrompt,
|
system_prompt: systemPrompt,
|
||||||
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
|
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
|
||||||
|
top_p: typeof fm.top_p === 'number' ? fm.top_p : null,
|
||||||
|
top_k: typeof fm.top_k === 'number' ? fm.top_k : null,
|
||||||
|
min_p: typeof fm.min_p === 'number' ? fm.min_p : null,
|
||||||
|
presence_penalty: typeof fm.presence_penalty === 'number' ? fm.presence_penalty : null,
|
||||||
tools: filteredTools,
|
tools: filteredTools,
|
||||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||||
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||||
|
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +388,14 @@ export function parseAgentsMd(content: string): ParseResult {
|
|||||||
return { agents, errors };
|
return { agents, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when a file at `<project>/AGENTS.md` is an agent registry, not Cursor/doc nav. */
|
||||||
|
export function isAgentRegistryMarkdown(content: string): boolean {
|
||||||
|
const firstLine = content.trimStart().split('\n')[0]?.trim() ?? '';
|
||||||
|
// BooCode monorepo root AGENTS.md is navigation only; registry is /data/AGENTS.md.
|
||||||
|
if (firstLine === '# Agent navigation') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- mtime-keyed cache + public API ----------------------------------------
|
// ---- mtime-keyed cache + public API ----------------------------------------
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
@@ -397,7 +484,7 @@ export async function getAgentsForProject(projectPath: string): Promise<AgentsRe
|
|||||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
|
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
|
||||||
errors.push(...r.errors);
|
errors.push(...r.errors);
|
||||||
}
|
}
|
||||||
if (projectContent !== null) {
|
if (projectContent !== null && isAgentRegistryMarkdown(projectContent)) {
|
||||||
const r = parseAgentsMd(projectContent);
|
const r = parseAgentsMd(projectContent);
|
||||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
|
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
|
||||||
errors.push(...r.errors);
|
errors.push(...r.errors);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { InferenceContext } from './inference/index.js';
|
import type { InferenceContext } from './inference/index.js';
|
||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
const NAMING_SYSTEM_PROMPT =
|
const NAMING_SYSTEM_PROMPT =
|
||||||
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
'You name chat sessions. Reply with ONLY the title. 4 to 6 words. No quotes, no punctuation, no prefix.';
|
||||||
|
|
||||||
const MAX_TITLE_CHARS = 60;
|
const MAX_TITLE_CHARS = 80;
|
||||||
|
|
||||||
function cleanTitle(raw: string): string {
|
function cleanTitle(raw: string): string {
|
||||||
let name = raw.trim();
|
let name = raw.trim();
|
||||||
@@ -18,27 +19,7 @@ function cleanTitle(raw: string): string {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamingResponse {
|
// TODO: wire suggestTags after task model validation
|
||||||
choices?: Array<{
|
|
||||||
message?: {
|
|
||||||
content?: string;
|
|
||||||
reasoning_content?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTitleSource(data: NamingResponse): string {
|
|
||||||
const choice = data.choices?.[0]?.message;
|
|
||||||
if (!choice) return '';
|
|
||||||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
|
||||||
const reasoning = choice.reasoning_content ?? '';
|
|
||||||
if (reasoning.length === 0) return '';
|
|
||||||
const lines = reasoning
|
|
||||||
.split('\n')
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
return lines[lines.length - 1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function maybeAutoNameChat(
|
export async function maybeAutoNameChat(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
@@ -64,51 +45,29 @@ export async function maybeAutoNameChat(
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (chat.name !== null && chat.name !== '') return;
|
if (chat.name !== null && chat.name !== '') return;
|
||||||
|
|
||||||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
|
||||||
SELECT model FROM sessions WHERE id = ${sessionId}
|
SELECT role, content FROM messages
|
||||||
`;
|
|
||||||
const model = sessionRows[0]?.model;
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
|
||||||
SELECT content FROM messages
|
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role IN ('user', 'assistant')
|
||||||
AND status = 'complete'
|
AND status IN ('complete', 'ok')
|
||||||
AND content <> ''
|
AND content <> ''
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1
|
LIMIT 2
|
||||||
`;
|
`;
|
||||||
if (!assistantMsg[0]) return;
|
const userMsg = firstMsgs.find(m => m.role === 'user');
|
||||||
|
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
|
||||||
|
if (!assistantMsg) return;
|
||||||
|
|
||||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
let namingInput = '';
|
||||||
|
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
|
||||||
|
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
|
||||||
|
|
||||||
const body = {
|
const raw = await taskModelCompletion({
|
||||||
model,
|
system: NAMING_SYSTEM_PROMPT,
|
||||||
messages: [
|
user: namingInput,
|
||||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
maxTokens: 30,
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: assistantText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 30,
|
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
stream: false,
|
|
||||||
chat_template_kwargs: { enable_thinking: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as NamingResponse;
|
|
||||||
const raw = pickTitleSource(data);
|
|
||||||
const name = cleanTitle(raw);
|
const name = cleanTitle(raw);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
|||||||
// turns + deeper exploration without changing the safety floor materially —
|
// turns + deeper exploration without changing the safety floor materially —
|
||||||
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
||||||
// mode this cap was guarding against.
|
// mode this cap was guarding against.
|
||||||
export const BUDGET_READ_ONLY = 50;
|
export const BUDGET_READ_ONLY = 100;
|
||||||
export const BUDGET_NON_READ_ONLY = 10;
|
export const BUDGET_NON_READ_ONLY = 100;
|
||||||
export const BUDGET_NO_AGENT = 50;
|
export const BUDGET_NO_AGENT = 100;
|
||||||
|
|
||||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||||
import type { PartInsert } from './parts.js';
|
import type { PartInsert } from './parts.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||||
|
|
||||||
export async function handleAbortOrError(
|
export async function handleAbortOrError(
|
||||||
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
|
|||||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
accumulated = stripToolMarkup(accumulated, { final: true });
|
||||||
// v1.8.2: persist a structured error metadata blob on genuine failures so
|
// v1.8.2: persist a structured error metadata blob on genuine failures so
|
||||||
// the bubble can render the reason on reload without re-deriving from the
|
// the bubble can render the reason on reload without re-deriving from the
|
||||||
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
||||||
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
|
|||||||
session: Session
|
session: Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, finishReason, promptTokens, completionTokens } = result;
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
|
const { finishReason, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: see executeToolPhase for the rationale.
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
const mctx = await modelContext.getModelContext(session.model);
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ export type {
|
|||||||
export type { ToolPhaseResult } from './tool-phase.js';
|
export type { ToolPhaseResult } from './tool-phase.js';
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
export { buildMessagesPayload } from './payload.js';
|
export { buildMessagesPayload } from './payload.js';
|
||||||
|
export { generateToolUseSummary } from './tool-summaries.js';
|
||||||
|
export type { ToolInfo } from './tool-summaries.js';
|
||||||
|
|||||||
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/llama_server_args.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/llama_server_args.py
|
||||||
|
|
||||||
|
// Each group is the full set of aliases (short + long) for one hard-denied
|
||||||
|
// flag, taken from the llama-server README. Flags NOT in this list pass
|
||||||
|
// through and override auto-set values via llama.cpp's last-wins CLI parsing.
|
||||||
|
const DENYLIST_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
||||||
|
// Model identity
|
||||||
|
new Set(['-m', '--model']),
|
||||||
|
new Set(['-mu', '--model-url']),
|
||||||
|
new Set(['-dr', '--docker-repo']),
|
||||||
|
new Set(['-hf', '-hfr', '--hf-repo']),
|
||||||
|
new Set(['-hff', '--hf-file']),
|
||||||
|
new Set(['-hfv', '-hfrv', '--hf-repo-v']),
|
||||||
|
new Set(['-hffv', '--hf-file-v']),
|
||||||
|
new Set(['-hft', '--hf-token']),
|
||||||
|
new Set(['-mm', '--mmproj']),
|
||||||
|
new Set(['-mmu', '--mmproj-url']),
|
||||||
|
// Networking
|
||||||
|
new Set(['--host']),
|
||||||
|
new Set(['--port']),
|
||||||
|
new Set(['--path']),
|
||||||
|
new Set(['--api-prefix']),
|
||||||
|
new Set(['--reuse-port']),
|
||||||
|
// Auth / TLS
|
||||||
|
new Set(['--api-key']),
|
||||||
|
new Set(['--api-key-file']),
|
||||||
|
new Set(['--ssl-key-file']),
|
||||||
|
new Set(['--ssl-cert-file']),
|
||||||
|
// Single-model server / UI
|
||||||
|
new Set(['--webui', '--no-webui']),
|
||||||
|
new Set(['--ui', '--no-ui']),
|
||||||
|
new Set(['--ui-config']),
|
||||||
|
new Set(['--ui-config-file']),
|
||||||
|
new Set(['--ui-mcp-proxy', '--no-ui-mcp-proxy']),
|
||||||
|
new Set(['--models-dir']),
|
||||||
|
new Set(['--models-preset']),
|
||||||
|
new Set(['--models-max']),
|
||||||
|
new Set(['--models-autoload', '--no-models-autoload']),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DENYLIST: ReadonlySet<string> = new Set(
|
||||||
|
DENYLIST_GROUPS.flatMap((g) => [...g]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function flagName(token: string): string | null {
|
||||||
|
if (!token.startsWith('-') || token === '-' || token === '--') return null;
|
||||||
|
if (token.length >= 2 && (token[1]!.match(/\d/) || token[1] === '.')) return null;
|
||||||
|
return token.split('=', 1)[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateExtraArgs(args?: Iterable<string>): string[] {
|
||||||
|
if (!args) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of args) {
|
||||||
|
const token = String(raw);
|
||||||
|
const flag = flagName(token);
|
||||||
|
if (flag !== null && DENYLIST.has(flag)) {
|
||||||
|
throw new Error(
|
||||||
|
`llama-server flag '${flag}' is managed and cannot be passed as an extra arg`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManagedFlag(flag: string): boolean {
|
||||||
|
return DENYLIST.has(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shadowing flag groups: pass-through flags that shadow first-class settings.
|
||||||
|
const CONTEXT_FLAGS = new Set(['-c', '--ctx-size']);
|
||||||
|
const CACHE_FLAGS = new Set(['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']);
|
||||||
|
const SPEC_FLAGS = new Set([
|
||||||
|
'--spec-default',
|
||||||
|
'--spec-type',
|
||||||
|
'--spec-ngram-size-n',
|
||||||
|
'--spec-ngram-size',
|
||||||
|
'--draft-min',
|
||||||
|
'--draft-max',
|
||||||
|
'--spec-draft-n-max',
|
||||||
|
'--spec-draft-n-min',
|
||||||
|
'--spec-draft-p-min',
|
||||||
|
'--spec-draft-p-split',
|
||||||
|
'--spec-ngram-mod-n-match',
|
||||||
|
'--spec-ngram-mod-n-min',
|
||||||
|
'--spec-ngram-mod-n-max',
|
||||||
|
]);
|
||||||
|
const TEMPLATE_FLAGS = new Set([
|
||||||
|
'--chat-template',
|
||||||
|
'--chat-template-file',
|
||||||
|
'--chat-template-kwargs',
|
||||||
|
'--jinja',
|
||||||
|
'--no-jinja',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOOLEAN_SHADOWING_FLAGS = new Set([
|
||||||
|
'--spec-default', '--jinja', '--no-jinja',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface StripOptions {
|
||||||
|
stripContext?: boolean;
|
||||||
|
stripCache?: boolean;
|
||||||
|
stripSpec?: boolean;
|
||||||
|
stripTemplate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripShadowingFlags(
|
||||||
|
args: Iterable<string>,
|
||||||
|
opts?: StripOptions,
|
||||||
|
): string[] {
|
||||||
|
const shadowing = new Set<string>();
|
||||||
|
if (opts?.stripContext !== false) for (const f of CONTEXT_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripCache !== false) for (const f of CACHE_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripSpec !== false) for (const f of SPEC_FLAGS) shadowing.add(f);
|
||||||
|
if (opts?.stripTemplate !== false) for (const f of TEMPLATE_FLAGS) shadowing.add(f);
|
||||||
|
|
||||||
|
const tokens = [...args].map(String);
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = tokens.length;
|
||||||
|
while (i < n) {
|
||||||
|
const tok = tokens[i]!;
|
||||||
|
const flag = flagName(tok);
|
||||||
|
if (flag === null || !shadowing.has(flag)) {
|
||||||
|
out.push(tok);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (BOOLEAN_SHADOWING_FLAGS.has(flag) || tok.includes('=')) {
|
||||||
|
i++;
|
||||||
|
} else if (i + 1 < n && flagName(tokens[i + 1]!) === null) {
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -37,6 +37,34 @@ export interface OpenAiMessage {
|
|||||||
// omit it and exercise the byte-stability surface directly through
|
// omit it and exercise the byte-stability surface directly through
|
||||||
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
|
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
|
||||||
// updates regardless of whether log is passed.
|
// updates regardless of whether log is passed.
|
||||||
|
function toolResultIdsFollowing(history: Message[], assistantIdx: number): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let j = assistantIdx + 1; j < history.length; j++) {
|
||||||
|
const row = history[j]!;
|
||||||
|
if (row.role === 'user' || row.role === 'assistant') break;
|
||||||
|
if (row.role === 'tool' && row.tool_results?.tool_call_id) {
|
||||||
|
ids.add(row.tool_results.tool_call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null {
|
||||||
|
for (let k = toolIdx - 1; k >= 0; k--) {
|
||||||
|
const row = history[k]!;
|
||||||
|
if (row.role === 'user') break;
|
||||||
|
if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean {
|
||||||
|
const assistant = history[assistantIdx]!;
|
||||||
|
if (!assistant.tool_calls?.length) return false;
|
||||||
|
const fulfilled = toolResultIdsFollowing(history, assistantIdx);
|
||||||
|
return assistant.tool_calls.every((tc) => fulfilled.has(tc.id));
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildMessagesPayload(
|
export async function buildMessagesPayload(
|
||||||
session: Session,
|
session: Session,
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
|
|||||||
if (m.role === 'tool') {
|
if (m.role === 'tool') {
|
||||||
const tr = m.tool_results;
|
const tr = m.tool_results;
|
||||||
if (!tr) continue;
|
if (!tr) continue;
|
||||||
|
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
|
||||||
|
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const outputText = tr.error
|
const outputText = tr.error
|
||||||
? `error: ${tr.error}`
|
? `error: ${tr.error}`
|
||||||
: typeof tr.output === 'string'
|
: typeof tr.output === 'string'
|
||||||
@@ -115,18 +147,27 @@ export async function buildMessagesPayload(
|
|||||||
content: m.content && m.content.length > 0 ? m.content : null,
|
content: m.content && m.content.length > 0 ? m.content : null,
|
||||||
};
|
};
|
||||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||||
|
if (assistantToolCallsArePayloadComplete(history, i)) {
|
||||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
type: 'function' as const,
|
type: 'function' as const,
|
||||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
// Orphaned tool_calls (no matching tool rows) are stripped so the
|
||||||
|
// upstream API does not reject the payload on the next user turn.
|
||||||
|
}
|
||||||
// v1.13.1-C: collapse reasoning_parts into a single string. The view
|
// v1.13.1-C: collapse reasoning_parts into a single string. The view
|
||||||
// returns them ordered by sequence; multiple reasoning parts on one
|
// returns them ordered by sequence; multiple reasoning parts on one
|
||||||
// message are rare but concat preserves ordering. Skip when absent.
|
// message are rare but concat preserves ordering. Skip when absent.
|
||||||
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
||||||
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
|
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
|
||||||
}
|
}
|
||||||
|
const hasPayload =
|
||||||
|
(msg.content != null && msg.content.trim().length > 0) ||
|
||||||
|
(msg.tool_calls != null && msg.tool_calls.length > 0) ||
|
||||||
|
(msg.reasoning != null && msg.reasoning.length > 0);
|
||||||
|
if (!hasPayload) continue;
|
||||||
out.push(msg);
|
out.push(msg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -142,7 +183,7 @@ export async function loadContext(
|
|||||||
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
||||||
const sessionRows = await sql<Session[]>`
|
const sessionRows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
agent_id, web_search_enabled
|
agent_id, web_search_enabled, allowed_read_paths
|
||||||
FROM sessions WHERE id = ${sessionId}
|
FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) return null;
|
if (sessionRows.length === 0) return null;
|
||||||
|
|||||||
@@ -6,29 +6,79 @@ import type { LanguageModel } from 'ai';
|
|||||||
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||||
// Tailscale topology and exposing it over the public internet is gated by
|
// Tailscale topology and exposing it over the public internet is gated by
|
||||||
// Authelia at the Caddy layer, not by API keys.
|
// Authelia at the Caddy layer, not by API keys.
|
||||||
|
//
|
||||||
|
// v2.4.1-sidecar: when the agent has llama_extra_args, route through
|
||||||
|
// llama-sidecar instead. A fresh provider is created per call (not cached)
|
||||||
|
// because the X-Agent-Flags header varies per agent. The llama-swap path
|
||||||
|
// stays cached since it has no per-request headers.
|
||||||
|
|
||||||
const cache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||||
|
|
||||||
function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
function getSwapProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||||
let provider = cache.get(baseURL);
|
let provider = swapCache.get(baseURL);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
provider = createOpenAICompatible({
|
provider = createOpenAICompatible({
|
||||||
name: 'llama-swap',
|
name: 'llama-swap',
|
||||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
|
||||||
// omits `stream_options.include_usage` from the request body. Without
|
|
||||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
|
||||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
|
||||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
|
||||||
// true here re-enables the per-stream usage payload across all models
|
|
||||||
// served via the llama-swap provider.
|
|
||||||
includeUsage: true,
|
includeUsage: true,
|
||||||
});
|
});
|
||||||
cache.set(baseURL, provider);
|
swapCache.set(baseURL, provider);
|
||||||
}
|
}
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upstreamModel(baseURL: string, modelId: string): LanguageModel {
|
function sidecarProvider(
|
||||||
return getProvider(baseURL).chatModel(modelId);
|
baseURL: string,
|
||||||
|
flags: string[],
|
||||||
|
): ReturnType<typeof createOpenAICompatible> {
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: 'llama-sidecar',
|
||||||
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
|
includeUsage: true,
|
||||||
|
headers: {
|
||||||
|
'X-Agent-Flags': flags.join(' '),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferenceRoute = 'swap' | 'sidecar';
|
||||||
|
|
||||||
|
export interface RoutingInfo {
|
||||||
|
route: InferenceRoute;
|
||||||
|
flags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentLike {
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigLike {
|
||||||
|
LLAMA_SWAP_URL: string;
|
||||||
|
LLAMA_SIDECAR_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||||
|
const flags = agent?.llama_extra_args;
|
||||||
|
if (flags && flags.length > 0) {
|
||||||
|
return { route: 'sidecar', flags };
|
||||||
|
}
|
||||||
|
return { route: 'swap', flags: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upstreamModel(
|
||||||
|
config: ConfigLike,
|
||||||
|
modelId: string,
|
||||||
|
agent?: AgentLike | null,
|
||||||
|
): LanguageModel {
|
||||||
|
const { route, flags } = resolveRoute(agent ?? null);
|
||||||
|
if (route === 'sidecar') {
|
||||||
|
const url = config.LLAMA_SIDECAR_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||||
|
}
|
||||||
|
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export async function runCapHitSummary(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId, signal } = args;
|
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||||
|
|
||||||
|
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
|
||||||
|
|
||||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||||
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
|
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export async function runCapHitSummary(
|
|||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
{ tools: null, temperature: agent?.temperature },
|
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||||
(delta) => {
|
(delta) => {
|
||||||
accumulated += delta;
|
accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
@@ -195,8 +197,6 @@ export async function runCapHitSummary(
|
|||||||
updated_at: sessRow!.updated_at,
|
updated_at: sessRow!.updated_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
|
|
||||||
|
|
||||||
// Status frame fires last so the dot color reflects the terminal state.
|
// Status frame fires last so the dot color reflects the terminal state.
|
||||||
// Success → idle, abort → idle (user-driven stop), error → error+reason.
|
// Success → idle, abort → idle (user-driven stop), error → error+reason.
|
||||||
if (summaryOk) {
|
if (summaryOk) {
|
||||||
@@ -346,7 +346,7 @@ export async function runDoomLoopSummary(
|
|||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
{ tools: null, temperature: agent?.temperature },
|
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||||
(delta) => {
|
(delta) => {
|
||||||
accumulated += delta;
|
accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
@@ -545,7 +545,7 @@ export async function runStepCapSummary(
|
|||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
{ tools: null, temperature: agent?.temperature },
|
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||||
(delta) => {
|
(delta) => {
|
||||||
accumulated += delta;
|
accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
|
|||||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import type { OpenAiMessage } from './payload.js';
|
import type { OpenAiMessage } from './payload.js';
|
||||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
import { extractToolCallBlocks } from './tool-call-parser.js';
|
||||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
|
||||||
import { extractToolCallBlocks } from './xml-parser.js';
|
|
||||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
@@ -31,6 +29,10 @@ interface StreamOptions {
|
|||||||
// (rare; we still omit from the request body to avoid OpenAI 400).
|
// (rare; we still omit from the request body to avoid OpenAI 400).
|
||||||
tools: ToolJsonSchema[] | null;
|
tools: ToolJsonSchema[] | null;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
top_p?: number | null;
|
||||||
|
top_k?: number | null;
|
||||||
|
min_p?: number | null;
|
||||||
|
presence_penalty?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK
|
// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK
|
||||||
@@ -155,7 +157,8 @@ export async function streamCompletion(
|
|||||||
opts: StreamOptions,
|
opts: StreamOptions,
|
||||||
onDelta: (content: string) => void,
|
onDelta: (content: string) => void,
|
||||||
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<StreamResult> {
|
): Promise<StreamResult> {
|
||||||
const aiMessages = toModelMessages(messages);
|
const aiMessages = toModelMessages(messages);
|
||||||
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
||||||
@@ -193,12 +196,15 @@ export async function streamCompletion(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools
|
...(aiTools
|
||||||
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
: {}),
|
: {}),
|
||||||
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
||||||
|
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
|
||||||
|
...(typeof opts.top_k === 'number' ? { topK: opts.top_k } : {}),
|
||||||
|
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -388,6 +394,10 @@ export async function executeStreamPhase(
|
|||||||
: toolJsonSchemas()
|
: toolJsonSchemas()
|
||||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||||
const effectiveTemperature = agent?.temperature;
|
const effectiveTemperature = agent?.temperature;
|
||||||
|
const effectiveTopP = agent?.top_p ?? undefined;
|
||||||
|
const effectiveTopK = agent?.top_k ?? undefined;
|
||||||
|
const effectiveMinP = agent?.min_p ?? undefined;
|
||||||
|
const effectivePresencePenalty = agent?.presence_penalty ?? undefined;
|
||||||
|
|
||||||
// v1.12.2: ctx_max lookup is cached after the first hit per model, so this
|
// v1.12.2: ctx_max lookup is cached after the first hit per model, so this
|
||||||
// is a Map probe in steady state. We capture nCtx once at the top of the
|
// is a Map probe in steady state. We capture nCtx once at the top of the
|
||||||
@@ -425,7 +435,7 @@ export async function executeStreamPhase(
|
|||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
{ tools: effectiveTools, temperature: effectiveTemperature },
|
{ tools: effectiveTools, temperature: effectiveTemperature, top_p: effectiveTopP, top_k: effectiveTopK, min_p: effectiveMinP, presence_penalty: effectivePresencePenalty },
|
||||||
(delta) => {
|
(delta) => {
|
||||||
state.accumulated += delta;
|
state.accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
@@ -449,7 +459,8 @@ export async function executeStreamPhase(
|
|||||||
}, USAGE_THROTTLE_MS - elapsed);
|
}, USAGE_THROTTLE_MS - elapsed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
agent,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
|
|||||||
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/tool_call_parser.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/tool_call_parser.py
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const XML_TOOL_OPEN = '<tool_call>';
|
||||||
|
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||||
|
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||||
|
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||||
|
|
||||||
|
export const TOOL_XML_SIGNALS = [XML_TOOL_OPEN, '<function=', INVOKE_TOOL_OPEN] as const;
|
||||||
|
|
||||||
|
export const TOOL_ERROR_PREFIXES = [
|
||||||
|
'Error',
|
||||||
|
'Search failed',
|
||||||
|
'Execution error',
|
||||||
|
'Blocked:',
|
||||||
|
'Exit code',
|
||||||
|
'Failed to fetch',
|
||||||
|
'Failed to resolve',
|
||||||
|
'No query provided',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DUPLICATE_CALL_NUDGE =
|
||||||
|
'You already made this exact call. Do not repeat the same tool ' +
|
||||||
|
'call. Try a different approach: fetch a URL from previous ' +
|
||||||
|
'results, use Python to process data you already have, or ' +
|
||||||
|
'provide your final answer now.';
|
||||||
|
|
||||||
|
export const TOOL_ERROR_NUDGE =
|
||||||
|
'\n\nThe tool call encountered an issue. Please try a different ' +
|
||||||
|
'approach or rephrase your request.';
|
||||||
|
|
||||||
|
export const BUDGET_EXHAUSTED_NUDGE =
|
||||||
|
'You have used all available tool calls. Based on everything you ' +
|
||||||
|
'have found so far, provide your final answer now. Do not call ' +
|
||||||
|
'any more tools.';
|
||||||
|
|
||||||
|
// ── Strip patterns ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOOL_CLOSED_PATS = [
|
||||||
|
/<tool_call>.*?<\/tool_call>/gs,
|
||||||
|
/<function=\w+>.*?<\/function>/gs,
|
||||||
|
/<invoke\s[^>]*>.*?<\/invoke>/gs,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_ALL_PATS = [
|
||||||
|
...TOOL_CLOSED_PATS,
|
||||||
|
/<tool_call>.*$/gs,
|
||||||
|
/<function=\w+>.*$/gs,
|
||||||
|
/<invoke\s[^>]*>.*$/gs,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Strip / signal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function stripToolMarkup(text: string, opts?: { final?: boolean }): string {
|
||||||
|
const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS;
|
||||||
|
for (const pat of pats) {
|
||||||
|
text = text.replace(pat, '');
|
||||||
|
}
|
||||||
|
return opts?.final ? text.trim() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasToolSignal(text: string): boolean {
|
||||||
|
return TOOL_XML_SIGNALS.some((s) => text.includes(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseToolCallsFromText (Unsloth port + Anthropic extension) ──────────
|
||||||
|
|
||||||
|
export interface OpenAiToolCall {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TC_JSON_START_RE = /<tool_call>\s*\{/g;
|
||||||
|
const TC_FUNC_START_RE = /<function=(\w+)>\s*/g;
|
||||||
|
const TC_END_TAG_RE = /<\/tool_call>/;
|
||||||
|
const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
|
||||||
|
const TC_PARAM_START_RE = /<parameter=(\w+)>\s*/g;
|
||||||
|
const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||||
|
|
||||||
|
const TC_INVOKE_START_RE = /<invoke\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||||
|
const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
|
||||||
|
const TC_INVOKE_PARAM_RE = /<parameter\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||||
|
const TC_INVOKE_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||||
|
|
||||||
|
function scanBalancedBraces(content: string, start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let i = start;
|
||||||
|
let inString = false;
|
||||||
|
while (i < content.length) {
|
||||||
|
const ch = content[i]!;
|
||||||
|
if (inString) {
|
||||||
|
if (ch === '\\' && i + 1 < content.length) {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inString = false;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (ch === '{') {
|
||||||
|
depth++;
|
||||||
|
} else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToolCallsFromText(
|
||||||
|
content: string,
|
||||||
|
opts?: { idOffset?: number },
|
||||||
|
): OpenAiToolCall[] {
|
||||||
|
const toolCalls: OpenAiToolCall[] = [];
|
||||||
|
const idOffset = opts?.idOffset ?? 0;
|
||||||
|
|
||||||
|
// Pattern 1: <tool_call>{json}</tool_call> -- balanced-brace JSON scanner.
|
||||||
|
// Skips braces inside JSON strings so nested objects parse correctly.
|
||||||
|
TC_JSON_START_RE.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TC_JSON_START_RE.exec(content)) !== null) {
|
||||||
|
const braceStart = m.index + m[0].length - 1;
|
||||||
|
const braceEnd = scanBalancedBraces(content, braceStart);
|
||||||
|
if (braceEnd === -1) continue;
|
||||||
|
const jsonStr = content.slice(braceStart, braceEnd + 1);
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||||
|
const name = typeof obj.name === 'string' ? obj.name : '';
|
||||||
|
let args: string;
|
||||||
|
const rawArgs = obj.arguments ?? {};
|
||||||
|
if (typeof rawArgs === 'string') {
|
||||||
|
args = rawArgs;
|
||||||
|
} else {
|
||||||
|
args = JSON.stringify(rawArgs);
|
||||||
|
}
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name, arguments: args },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// malformed JSON -- skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: <function=name><parameter=key>value -- closing tags optional.
|
||||||
|
// Body boundary uses </tool_call> or next <function= (not </function>,
|
||||||
|
// because code parameter values can contain that literal).
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
TC_FUNC_START_RE.lastIndex = 0;
|
||||||
|
const funcStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
while ((m = TC_FUNC_START_RE.exec(content)) !== null) {
|
||||||
|
funcStarts.push({ match: m, name: m[1]! });
|
||||||
|
}
|
||||||
|
for (let idx = 0; idx < funcStarts.length; idx++) {
|
||||||
|
const { match: fm, name: funcName } = funcStarts[idx]!;
|
||||||
|
const bodyStart = fm.index + fm[0].length;
|
||||||
|
const nextFunc = idx + 1 < funcStarts.length
|
||||||
|
? funcStarts[idx + 1]!.match.index
|
||||||
|
: content.length;
|
||||||
|
const endTag = TC_END_TAG_RE.exec(content.slice(bodyStart));
|
||||||
|
let bodyEnd = endTag ? bodyStart + endTag.index : content.length;
|
||||||
|
bodyEnd = Math.min(bodyEnd, nextFunc);
|
||||||
|
let body = content.slice(bodyStart, bodyEnd);
|
||||||
|
body = body.replace(TC_FUNC_CLOSE_RE, '');
|
||||||
|
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
TC_PARAM_START_RE.lastIndex = 0;
|
||||||
|
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
let pm: RegExpExecArray | null;
|
||||||
|
while ((pm = TC_PARAM_START_RE.exec(body)) !== null) {
|
||||||
|
paramStarts.push({ match: pm, name: pm[1]! });
|
||||||
|
}
|
||||||
|
if (paramStarts.length === 1) {
|
||||||
|
// Single param: take everything to body end so embedded
|
||||||
|
// </parameter> in code strings is preserved.
|
||||||
|
const p = paramStarts[0]!;
|
||||||
|
let val = body.slice(p.match.index + p.match[0].length);
|
||||||
|
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
} else {
|
||||||
|
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||||
|
const p = paramStarts[pidx]!;
|
||||||
|
const valStart = p.match.index + p.match[0].length;
|
||||||
|
const nextParam = pidx + 1 < paramStarts.length
|
||||||
|
? paramStarts[pidx + 1]!.match.index
|
||||||
|
: body.length;
|
||||||
|
let val = body.slice(valStart, nextParam);
|
||||||
|
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: funcName, arguments: JSON.stringify(args) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: <invoke name="..."><parameter name="...">value -- Anthropic
|
||||||
|
// shape that qwen3.6 drifts to from Claude Code documentation residue.
|
||||||
|
// Closing tags optional; same single-param fast path as pattern 2.
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
TC_INVOKE_START_RE.lastIndex = 0;
|
||||||
|
const invokeStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
while ((m = TC_INVOKE_START_RE.exec(content)) !== null) {
|
||||||
|
const name = (m[1] ?? m[2] ?? '').trim();
|
||||||
|
if (name) invokeStarts.push({ match: m, name });
|
||||||
|
}
|
||||||
|
for (let idx = 0; idx < invokeStarts.length; idx++) {
|
||||||
|
const { match: im, name: invokeName } = invokeStarts[idx]!;
|
||||||
|
const bodyStart = im.index + im[0].length;
|
||||||
|
const nextInvoke = idx + 1 < invokeStarts.length
|
||||||
|
? invokeStarts[idx + 1]!.match.index
|
||||||
|
: content.length;
|
||||||
|
const closeTag = content.slice(bodyStart).match(/<\/invoke>/);
|
||||||
|
let bodyEnd = closeTag ? bodyStart + (closeTag.index ?? 0) : content.length;
|
||||||
|
bodyEnd = Math.min(bodyEnd, nextInvoke);
|
||||||
|
let body = content.slice(bodyStart, bodyEnd);
|
||||||
|
body = body.replace(TC_INVOKE_CLOSE_RE, '');
|
||||||
|
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
TC_INVOKE_PARAM_RE.lastIndex = 0;
|
||||||
|
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||||
|
let pm: RegExpExecArray | null;
|
||||||
|
while ((pm = TC_INVOKE_PARAM_RE.exec(body)) !== null) {
|
||||||
|
const pname = (pm[1] ?? pm[2] ?? '').trim();
|
||||||
|
if (pname) paramStarts.push({ match: pm, name: pname });
|
||||||
|
}
|
||||||
|
if (paramStarts.length === 1) {
|
||||||
|
const p = paramStarts[0]!;
|
||||||
|
let val = body.slice(p.match.index + p.match[0].length);
|
||||||
|
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
} else {
|
||||||
|
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||||
|
const p = paramStarts[pidx]!;
|
||||||
|
const valStart = p.match.index + p.match[0].length;
|
||||||
|
const nextParam = pidx + 1 < paramStarts.length
|
||||||
|
? paramStarts[pidx + 1]!.match.index
|
||||||
|
: body.length;
|
||||||
|
let val = body.slice(valStart, nextParam);
|
||||||
|
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||||
|
args[p.name] = val.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
id: `call_${idOffset + toolCalls.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: invokeName, arguments: JSON.stringify(args) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BooCode streaming helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParsedCall {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||||
|
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||||
|
|
||||||
|
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return true;
|
||||||
|
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
|
||||||
|
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||||
|
for (const value of Object.values(args)) {
|
||||||
|
if (isPlaceholderArgValue(value)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||||
|
console.debug(
|
||||||
|
{ toolName: parsed.name, args: parsed.args },
|
||||||
|
'rejected placeholder tool call at parse time',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
||||||
|
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
||||||
|
|
||||||
|
export function parseXmlToolCall(block: string): ParsedCall | null {
|
||||||
|
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
||||||
|
if (!nameMatch || !nameMatch[1]) return null;
|
||||||
|
const name = nameMatch[1].trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
||||||
|
const key = (m[1] ?? '').trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[2] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOKE_NAME_RE =
|
||||||
|
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
||||||
|
const INVOKE_PARAM_RE =
|
||||||
|
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
||||||
|
|
||||||
|
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||||
|
const nameMatch = block.match(INVOKE_NAME_RE);
|
||||||
|
if (!nameMatch) return null;
|
||||||
|
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
||||||
|
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const raw = (m[4] ?? '').trim();
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
args[key] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||||
|
|
||||||
|
export function partialXmlOpenerStart(s: string): number {
|
||||||
|
let earliest = -1;
|
||||||
|
for (const op of ALL_OPENERS) {
|
||||||
|
const idx = s.indexOf(op);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
if (earliest === -1 || idx < earliest) earliest = idx;
|
||||||
|
}
|
||||||
|
if (earliest !== -1) return earliest;
|
||||||
|
const lastLt = s.lastIndexOf('<');
|
||||||
|
if (lastLt === -1) return -1;
|
||||||
|
const suffix = s.slice(lastLt);
|
||||||
|
for (const op of ALL_OPENERS) {
|
||||||
|
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallExtraction {
|
||||||
|
flushed: string;
|
||||||
|
calls: ParsedCall[];
|
||||||
|
remaining: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenerSpec {
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
parse: (block: string) => ParsedCall | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
||||||
|
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
||||||
|
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||||
|
let flushed = '';
|
||||||
|
const calls: ParsedCall[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos < buffer.length) {
|
||||||
|
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
||||||
|
for (const spec of OPENER_SPECS) {
|
||||||
|
const openIdx = buffer.indexOf(spec.open, pos);
|
||||||
|
if (openIdx === -1) continue;
|
||||||
|
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
||||||
|
if (closeIdx === -1) continue;
|
||||||
|
if (next === null || openIdx < next.openIdx) {
|
||||||
|
next = { spec, openIdx, closeIdx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next === null) break;
|
||||||
|
|
||||||
|
if (next.openIdx > pos) {
|
||||||
|
flushed += buffer.slice(pos, next.openIdx);
|
||||||
|
}
|
||||||
|
const blockEnd = next.closeIdx + next.spec.close.length;
|
||||||
|
const block = buffer.slice(next.openIdx, blockEnd);
|
||||||
|
const parsed = next.spec.parse(block);
|
||||||
|
if (parsed) {
|
||||||
|
if (hasPlaceholderArgs(parsed.args)) {
|
||||||
|
logRejectedPlaceholder(parsed);
|
||||||
|
flushed += block;
|
||||||
|
} else {
|
||||||
|
calls.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = blockEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = buffer.slice(pos);
|
||||||
|
const partialIdx = partialXmlOpenerStart(tail);
|
||||||
|
if (partialIdx === -1) {
|
||||||
|
flushed += tail;
|
||||||
|
return { flushed, calls, remaining: '' };
|
||||||
|
}
|
||||||
|
if (partialIdx > 0) {
|
||||||
|
flushed += tail.slice(0, partialIdx);
|
||||||
|
}
|
||||||
|
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Session, ToolCall } from '../../types/api.js';
|
import type { Agent, Session, ToolCall } from '../../types/api.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
||||||
@@ -14,6 +15,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
|
|||||||
// Resolves the grant root before pausing the loop so the user is never
|
// Resolves the grant root before pausing the loop so the user is never
|
||||||
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||||
|
import { stripToolMarkup } from './tool-call-parser.js';
|
||||||
import type {
|
import type {
|
||||||
InferenceContext,
|
InferenceContext,
|
||||||
StreamResult,
|
StreamResult,
|
||||||
@@ -97,10 +99,12 @@ export async function executeToolPhase(
|
|||||||
result: StreamResult,
|
result: StreamResult,
|
||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
|
const { toolCalls, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
||||||
@@ -260,6 +264,31 @@ export async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (agent && !matchToolGlob(tc.name, agent.tools)) {
|
||||||
|
const stored = {
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: null,
|
||||||
|
truncated: false,
|
||||||
|
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
|
||||||
|
};
|
||||||
|
await insertParts(
|
||||||
|
ctx.sql,
|
||||||
|
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||||
|
...p,
|
||||||
|
message_id: toolMessageId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: toolMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: stored.output,
|
||||||
|
truncated: false,
|
||||||
|
error: stored.error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
|
|||||||
81
apps/server/src/services/inference/tool-summaries.ts
Normal file
81
apps/server/src/services/inference/tool-summaries.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* v2.0.5: Tool-use summary generation.
|
||||||
|
*
|
||||||
|
* After a batch of tool calls completes, fire a cheap LLM call to generate
|
||||||
|
* a "git-commit-subject-style" one-liner label describing what the tools
|
||||||
|
* accomplished. Ported from the Qwen Code source recon.
|
||||||
|
*/
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
|
const TOOL_SUMMARY_SYSTEM_PROMPT = `Write a short summary label describing what these tool calls accomplished. Think git-commit-subject, not sentence. Past tense, most distinctive noun. Max 30 characters. Output ONLY the label.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Searched in auth/
|
||||||
|
- Fixed NPE in UserService
|
||||||
|
- Created signup endpoint
|
||||||
|
- Read config.json
|
||||||
|
- Ran failing tests`;
|
||||||
|
|
||||||
|
const INPUT_TRUNCATE = 300;
|
||||||
|
const MAX_SUMMARY_LENGTH = 100;
|
||||||
|
|
||||||
|
export interface ToolInfo {
|
||||||
|
name: string;
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateToolUseSummary(opts: {
|
||||||
|
tools: ToolInfo[];
|
||||||
|
llamaSwapUrl: string;
|
||||||
|
model: string;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const { tools, llamaSwapUrl, model, log, signal } = opts;
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
if (signal?.aborted) return null;
|
||||||
|
|
||||||
|
const toolText = tools
|
||||||
|
.map(t => `Tool: ${t.name}\nInput: ${t.input.slice(0, INPUT_TRUNCATE)}\nOutput: ${t.output.slice(0, INPUT_TRUNCATE)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${llamaSwapUrl}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: TOOL_SUMMARY_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: toolText },
|
||||||
|
],
|
||||||
|
max_tokens: 30,
|
||||||
|
temperature: 0.2,
|
||||||
|
stream: false,
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
log.debug({ status: res.status }, 'tool-summary: LLM request failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
const raw = data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||||
|
if (!raw) return null;
|
||||||
|
// Clean: strip quotes, "Label:" prefix, cap length
|
||||||
|
let cleaned = raw.split('\n')[0]?.trim() ?? '';
|
||||||
|
cleaned = cleaned
|
||||||
|
.replace(/^[-*•]\s+/, '')
|
||||||
|
.replace(/^["'`‘’“”]|["'`‘’“”]$/g, '')
|
||||||
|
.replace(/^(label|summary)\s*:\s*/i, '')
|
||||||
|
.trim();
|
||||||
|
return cleaned.length > MAX_SUMMARY_LENGTH
|
||||||
|
? cleaned.slice(0, MAX_SUMMARY_LENGTH).trim()
|
||||||
|
: cleaned || null;
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ err: err instanceof Error ? err.message : String(err) }, 'tool-summary: error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { ALL_TOOLS } from '../tools.js';
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
import { resolveProjectRoot } from '../path_guard.js';
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
@@ -254,6 +255,16 @@ export async function runAssistantTurn(
|
|||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserMsg?.content) {
|
||||||
|
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||||
|
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||||
|
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
let result: StreamResult;
|
let result: StreamResult;
|
||||||
@@ -281,7 +292,7 @@ export async function runAssistantTurn(
|
|||||||
// ---- tool phase ----
|
// ---- tool phase ----
|
||||||
let toolPhaseResult: ToolPhaseResult;
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
try {
|
try {
|
||||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Tool phase errors are unexpected (individual tool failures are
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
// caught inside executeToolPhase). Log and break.
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
// v1.10.5: XML-tag tool-call fallback. Some models emit
|
|
||||||
// <tool_call><function=foo><parameter=key>value</parameter></function></tool_call>
|
|
||||||
// in plain content instead of using the OpenAI tool_calls JSON channel.
|
|
||||||
// The streaming loop in stream-phase.ts extracts these blocks via these helpers.
|
|
||||||
//
|
|
||||||
// v1.13.16: also recognize Anthropic <invoke name="..."><parameter name="...">
|
|
||||||
// markup. qwen3.6-35b-a3b-mxfp4 drifts to this format when prompted as an
|
|
||||||
// "Architect"-style agent because Claude Code documentation in its
|
|
||||||
// pre-training data uses this shape. Both formats route through the same
|
|
||||||
// synthetic ToolCall path with shared xml_call_${idx} IDs; downstream
|
|
||||||
// dispatch handles unknown tool names with a richer error (see
|
|
||||||
// tool-suggestions.ts + tool-phase.ts).
|
|
||||||
|
|
||||||
export const XML_TOOL_OPEN = '<tool_call>';
|
|
||||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
|
||||||
|
|
||||||
// v1.13.16: Anthropic <invoke> opener is matched by prefix (not the full
|
|
||||||
// `<invoke ...>` tag) because attributes follow. Closer is the literal tag.
|
|
||||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
|
||||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
|
||||||
|
|
||||||
export interface ParsedCall {
|
|
||||||
name: string;
|
|
||||||
args: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace
|
|
||||||
// around `=` (e.g. `<function = view_file>`). Name capture is non-whitespace,
|
|
||||||
// non-`>` so a stray space doesn't get absorbed into the function name.
|
|
||||||
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
|
||||||
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
|
||||||
|
|
||||||
export function parseXmlToolCall(block: string): ParsedCall | null {
|
|
||||||
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
|
||||||
if (!nameMatch || !nameMatch[1]) return null;
|
|
||||||
const name = nameMatch[1].trim();
|
|
||||||
if (!name) return null;
|
|
||||||
const args: Record<string, unknown> = {};
|
|
||||||
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
|
||||||
const key = (m[1] ?? '').trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const raw = (m[2] ?? '').trim();
|
|
||||||
try {
|
|
||||||
args[key] = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
args[key] = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { name, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.13.16: Anthropic-flavor parser. Same JSON-parse-with-string-fallback
|
|
||||||
// shape as parseXmlToolCall so the dispatch layer doesn't need to care which
|
|
||||||
// flavor produced the call.
|
|
||||||
const INVOKE_NAME_RE =
|
|
||||||
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
|
||||||
const INVOKE_PARAM_RE =
|
|
||||||
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
|
||||||
|
|
||||||
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
|
||||||
const nameMatch = block.match(INVOKE_NAME_RE);
|
|
||||||
if (!nameMatch) return null;
|
|
||||||
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
|
||||||
if (!name) return null;
|
|
||||||
const args: Record<string, unknown> = {};
|
|
||||||
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
|
||||||
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
|
||||||
if (!key) continue;
|
|
||||||
const raw = (m[4] ?? '').trim();
|
|
||||||
try {
|
|
||||||
args[key] = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
args[key] = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { name, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate the first character that begins (or completely contains) an
|
|
||||||
// unfinished opener (either flavor) in `s`. Returns -1 when `s` can be
|
|
||||||
// flushed to the client in full without risking a partial tag leak.
|
|
||||||
// Case 1: a full opener (`<tool_call>` or `<invoke`) with no matching
|
|
||||||
// closer — caller must keep everything from that index forward
|
|
||||||
// until the next chunk arrives with the closer.
|
|
||||||
// Case 2: `s` ends with a strict prefix of either opener (e.g. `<tool_c`
|
|
||||||
// or `<invo`). Caller must keep just that suffix in the buffer.
|
|
||||||
// Note: case 1 assumes the calling loop already extracted every complete
|
|
||||||
// block before reaching this check.
|
|
||||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
|
||||||
|
|
||||||
export function partialXmlOpenerStart(s: string): number {
|
|
||||||
let earliest = -1;
|
|
||||||
for (const op of ALL_OPENERS) {
|
|
||||||
const idx = s.indexOf(op);
|
|
||||||
if (idx === -1) continue;
|
|
||||||
if (earliest === -1 || idx < earliest) earliest = idx;
|
|
||||||
}
|
|
||||||
if (earliest !== -1) return earliest;
|
|
||||||
const lastLt = s.lastIndexOf('<');
|
|
||||||
if (lastLt === -1) return -1;
|
|
||||||
const suffix = s.slice(lastLt);
|
|
||||||
for (const op of ALL_OPENERS) {
|
|
||||||
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.13.16: unified extraction. Replaces the inline loop that used to live
|
|
||||||
// in stream-phase.ts. Pure function — returns the visible text to flush,
|
|
||||||
// the parsed tool-call payloads in source order, and the buffer remainder
|
|
||||||
// to retain for the next streaming chunk. Parse failures are silently
|
|
||||||
// dropped (matches the pre-v1.13.16 behavior — leaking partial XML to the
|
|
||||||
// chat looks worse than swallowing a bad block).
|
|
||||||
export interface ToolCallExtraction {
|
|
||||||
flushed: string;
|
|
||||||
calls: ParsedCall[];
|
|
||||||
remaining: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenerSpec {
|
|
||||||
open: string;
|
|
||||||
close: string;
|
|
||||||
parse: (block: string) => ParsedCall | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
|
||||||
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
|
||||||
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
|
||||||
let flushed = '';
|
|
||||||
const calls: ParsedCall[] = [];
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
while (pos < buffer.length) {
|
|
||||||
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
|
||||||
for (const spec of OPENER_SPECS) {
|
|
||||||
const openIdx = buffer.indexOf(spec.open, pos);
|
|
||||||
if (openIdx === -1) continue;
|
|
||||||
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
|
||||||
if (closeIdx === -1) continue;
|
|
||||||
if (next === null || openIdx < next.openIdx) {
|
|
||||||
next = { spec, openIdx, closeIdx };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (next === null) break;
|
|
||||||
|
|
||||||
if (next.openIdx > pos) {
|
|
||||||
flushed += buffer.slice(pos, next.openIdx);
|
|
||||||
}
|
|
||||||
const blockEnd = next.closeIdx + next.spec.close.length;
|
|
||||||
const block = buffer.slice(next.openIdx, blockEnd);
|
|
||||||
const parsed = next.spec.parse(block);
|
|
||||||
if (parsed) calls.push(parsed);
|
|
||||||
pos = blockEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tail = buffer.slice(pos);
|
|
||||||
const partialIdx = partialXmlOpenerStart(tail);
|
|
||||||
if (partialIdx === -1) {
|
|
||||||
flushed += tail;
|
|
||||||
return { flushed, calls, remaining: '' };
|
|
||||||
}
|
|
||||||
if (partialIdx > 0) {
|
|
||||||
flushed += tail.slice(0, partialIdx);
|
|
||||||
}
|
|
||||||
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
|
||||||
}
|
|
||||||
@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
|
|||||||
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||||
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||||
// paths match the same patterns. Empty or root-only paths return false.
|
// paths match the same patterns. Empty or root-only paths return false.
|
||||||
|
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
|
||||||
|
'.env.example',
|
||||||
|
'.env.sample',
|
||||||
|
'.env.template',
|
||||||
|
'.env.defaults',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isSecretPath(relPath: string): boolean {
|
export function isSecretPath(relPath: string): boolean {
|
||||||
if (!relPath) return false;
|
if (!relPath) return false;
|
||||||
const normalized = relPath.replace(/\\/g, '/');
|
const normalized = relPath.replace(/\\/g, '/');
|
||||||
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
|
|||||||
if (segments.length === 0) return false;
|
if (segments.length === 0) return false;
|
||||||
const base = segments[segments.length - 1]!;
|
const base = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
|
||||||
|
|
||||||
for (const compiled of COMPILED) {
|
for (const compiled of COMPILED) {
|
||||||
if (compiled.mode === 'basename') {
|
if (compiled.mode === 'basename') {
|
||||||
if (compiled.regex.test(base)) return true;
|
if (compiled.regex.test(base)) return true;
|
||||||
|
|||||||
148
apps/server/src/services/skill-invoke.ts
Normal file
148
apps/server/src/services/skill-invoke.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
export const DEFAULT_SKILL_USER_MESSAGE = 'Apply this skill.';
|
||||||
|
|
||||||
|
export interface SkillInvokeTransactionResult {
|
||||||
|
synth_assistant_id: string;
|
||||||
|
tool_message_id: string;
|
||||||
|
user_message_id: string;
|
||||||
|
assistant_message_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillInvokeToolCall {
|
||||||
|
id: string;
|
||||||
|
name: 'skill_use';
|
||||||
|
args: { name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillInvokeSessionFrame = Record<string, unknown> & { type: string };
|
||||||
|
|
||||||
|
export async function runSkillInvokeTransaction(
|
||||||
|
sql: Sql,
|
||||||
|
args: {
|
||||||
|
sessionId: string;
|
||||||
|
chatId: string;
|
||||||
|
skillName: string;
|
||||||
|
skillBody: string;
|
||||||
|
userText: string;
|
||||||
|
},
|
||||||
|
): Promise<{ result: SkillInvokeTransactionResult; toolCall: SkillInvokeToolCall }> {
|
||||||
|
const toolCallId = randomUUID();
|
||||||
|
const toolCall: SkillInvokeToolCall = {
|
||||||
|
id: toolCallId,
|
||||||
|
name: 'skill_use',
|
||||||
|
args: { name: args.skillName },
|
||||||
|
};
|
||||||
|
const toolResults = {
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
output: args.skillBody,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||||
|
id: toolCallId,
|
||||||
|
name: 'skill_use',
|
||||||
|
args: { name: args.skillName },
|
||||||
|
} as never)})
|
||||||
|
`;
|
||||||
|
const [toolMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${args.sessionId}, ${args.chatId}, 'tool', '', 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||||
|
`;
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${args.sessionId}, ${args.chatId}, 'user', ${args.userText}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${args.sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${args.chatId}`;
|
||||||
|
return {
|
||||||
|
synth_assistant_id: synthAssistant!.id,
|
||||||
|
tool_message_id: toolMsg!.id,
|
||||||
|
user_message_id: userMsg!.id,
|
||||||
|
assistant_message_id: assistantMsg!.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { result, toolCall };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSkillInvokeSyntheticFrames(
|
||||||
|
chatId: string,
|
||||||
|
result: SkillInvokeTransactionResult,
|
||||||
|
toolCall: SkillInvokeToolCall,
|
||||||
|
skillBody: string,
|
||||||
|
): SkillInvokeSessionFrame[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_call',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call: toolCall,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: result.tool_message_id,
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
chat_id: chatId,
|
||||||
|
output: skillBody,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSkillInvokeUserFrames(
|
||||||
|
chatId: string,
|
||||||
|
userMessageId: string,
|
||||||
|
userText: string,
|
||||||
|
): SkillInvokeSessionFrame[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delta',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: userText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { pathGuard, PathScopeError } from './path_guard.js';
|
|||||||
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
||||||
// is re-read without a restart. No watcher.
|
// is re-read without a restart. No watcher.
|
||||||
|
|
||||||
const SKILLS_ROOT = '/data/skills';
|
const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills';
|
||||||
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
||||||
const LIST_CACHE_TTL_MS = 60_000;
|
const LIST_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import type { Agent, Project, Session } from '../types/api.js';
|
import type { Agent, Project, Session } from '../types/api.js';
|
||||||
import { getAgentsMtimes } from './agents.js';
|
import { getAgentsMtimes } from './agents.js';
|
||||||
|
import { resolveRoute } from './inference/provider.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
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.`;
|
`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.`;
|
||||||
@@ -98,6 +99,7 @@ export interface PrefixFingerprint {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrefixDrift {
|
export interface PrefixDrift {
|
||||||
@@ -125,6 +127,7 @@ interface ObservedInputs {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ObserverEntry {
|
interface ObserverEntry {
|
||||||
@@ -183,6 +186,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
||||||
has_session_override: sessionPrompt.length > 0,
|
has_session_override: sessionPrompt.length > 0,
|
||||||
has_project_override: projectPrompt.length > 0,
|
has_project_override: projectPrompt.length > 0,
|
||||||
|
route: resolveRoute(agent).route,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fingerprint: PrefixFingerprint = {
|
const fingerprint: PrefixFingerprint = {
|
||||||
@@ -199,6 +203,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
||||||
has_session_override: inputs.has_session_override,
|
has_session_override: inputs.has_session_override,
|
||||||
has_project_override: inputs.has_project_override,
|
has_project_override: inputs.has_project_override,
|
||||||
|
route: inputs.route,
|
||||||
};
|
};
|
||||||
|
|
||||||
let drift: PrefixDrift | null = null;
|
let drift: PrefixDrift | null = null;
|
||||||
|
|||||||
68
apps/server/src/services/task-model.ts
Normal file
68
apps/server/src/services/task-model.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { loadConfig, type Config } from '../config.js';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export async function taskModelCompletion(opts: {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
fallbackModel?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const maxTokens = opts.maxTokens ?? 30;
|
||||||
|
const temperature = opts.temperature ?? 0.3;
|
||||||
|
|
||||||
|
const { url, model } = resolveEndpoint(config, opts.fallbackModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.system },
|
||||||
|
{ role: 'user', content: opts.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(() => '');
|
||||||
|
console.warn(`task-model: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const reasoning = choice.reasoning_content ?? '';
|
||||||
|
if (reasoning.length === 0) return '';
|
||||||
|
const lines = reasoning.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||||
|
return lines[lines.length - 1] ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('task-model: request failed', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndpoint(
|
||||||
|
config: Config,
|
||||||
|
fallbackModel?: string,
|
||||||
|
): { url: string; model: string } {
|
||||||
|
if (config.TASK_MODEL_URL) {
|
||||||
|
return { url: config.TASK_MODEL_URL, model: 'gemma-3-270m-it' };
|
||||||
|
}
|
||||||
|
const model = config.FAST_MODEL ?? fallbackModel ?? config.DEFAULT_MODEL;
|
||||||
|
return { url: config.LLAMA_SWAP_URL, model };
|
||||||
|
}
|
||||||
19
apps/server/src/services/task-search-rewrite.ts
Normal file
19
apps/server/src/services/task-search-rewrite.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You rewrite user messages into concise web search queries. Reply with ONLY the search query. 3 to 6 words. No quotes, no explanation.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 500;
|
||||||
|
const FALLBACK_CHARS = 60;
|
||||||
|
|
||||||
|
export async function rewriteSearchQuery(userMessage: string): Promise<string> {
|
||||||
|
const input = userMessage.slice(0, MAX_INPUT_CHARS);
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 20,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
if (result.length > 0) return result;
|
||||||
|
return userMessage.slice(0, FALLBACK_CHARS).trim();
|
||||||
|
}
|
||||||
24
apps/server/src/services/task-summary.ts
Normal file
24
apps/server/src/services/task-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 1000;
|
||||||
|
|
||||||
|
export async function oneLineSummary(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const lastPairs = messages.slice(-6);
|
||||||
|
let input = lastPairs
|
||||||
|
.map((m) => `${m.role}: ${m.content}`)
|
||||||
|
.join('\n');
|
||||||
|
if (input.length > MAX_INPUT_CHARS) {
|
||||||
|
input = input.slice(0, MAX_INPUT_CHARS);
|
||||||
|
}
|
||||||
|
return taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/server/src/services/task-tags.ts
Normal file
22
apps/server/src/services/task-tags.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".';
|
||||||
|
|
||||||
|
export async function suggestTags(
|
||||||
|
userMessage: string,
|
||||||
|
assistantReply: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`;
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
if (result.length === 0) return [];
|
||||||
|
return result
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter((t) => t.length > 0 && t.length <= 30);
|
||||||
|
}
|
||||||
347
apps/server/src/services/web/html-to-md.ts
Normal file
347
apps/server/src/services/web/html-to-md.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
// Ported from studio/backend/core/inference/_html_to_md.py.
|
||||||
|
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/_html_to_md.py
|
||||||
|
|
||||||
|
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
|
||||||
|
|
||||||
|
type Document = DefaultTreeAdapterTypes.Document;
|
||||||
|
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
||||||
|
type Element = DefaultTreeAdapterTypes.Element;
|
||||||
|
type TextNode = DefaultTreeAdapterTypes.TextNode;
|
||||||
|
|
||||||
|
const SKIP_TAGS = new Set([
|
||||||
|
'script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'p', 'div', 'section', 'article', 'main', 'aside', 'figure',
|
||||||
|
'figcaption', 'details', 'summary', 'dl', 'dt', 'dd',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||||
|
|
||||||
|
const INLINE_EMPHASIS: Record<string, string> = {
|
||||||
|
strong: '**', b: '**', em: '*', i: '*',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isElement(node: ChildNode): node is Element {
|
||||||
|
return 'tagName' in node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isText(node: ChildNode): node is TextNode {
|
||||||
|
return node.nodeName === '#text';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkdownRenderer {
|
||||||
|
private out: string[] = [];
|
||||||
|
|
||||||
|
private inLink = false;
|
||||||
|
private linkHref: string | null = null;
|
||||||
|
private linkTextParts: string[] = [];
|
||||||
|
|
||||||
|
private listStack: string[] = [];
|
||||||
|
private olCounter: number[] = [];
|
||||||
|
|
||||||
|
private inTable = false;
|
||||||
|
private currentRow: string[] = [];
|
||||||
|
private cellParts: string[] = [];
|
||||||
|
private inCell = false;
|
||||||
|
private headerRowDone = false;
|
||||||
|
private rowHasTh = false;
|
||||||
|
private isFirstRow = false;
|
||||||
|
|
||||||
|
private inPre = false;
|
||||||
|
private preParts: string[] = [];
|
||||||
|
private preLanguage: string | null = null;
|
||||||
|
private inInlineCode = false;
|
||||||
|
|
||||||
|
private bqStack: string[][] = [];
|
||||||
|
|
||||||
|
private emit(text: string): void {
|
||||||
|
if (this.inLink) {
|
||||||
|
this.linkTextParts.push(text);
|
||||||
|
} else if (this.inCell) {
|
||||||
|
this.cellParts.push(text);
|
||||||
|
} else if (this.inPre) {
|
||||||
|
this.preParts.push(text);
|
||||||
|
} else if (this.bqStack.length > 0) {
|
||||||
|
this.bqStack[this.bqStack.length - 1]!.push(text);
|
||||||
|
} else {
|
||||||
|
this.out.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefixBlockquote(content: string): string {
|
||||||
|
content = content.replace(/[ \t]+$/gm, '');
|
||||||
|
content = content.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
if (!content) return '';
|
||||||
|
return content.split('\n').map(line =>
|
||||||
|
line.trim() ? '> ' + line : '>'
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishCell(): void {
|
||||||
|
if (!this.inCell) return;
|
||||||
|
this.inCell = false;
|
||||||
|
let cellText = this.cellParts.join('').trim().replace(/\n/g, ' ');
|
||||||
|
cellText = cellText.replace(/\|/g, '\\|');
|
||||||
|
this.currentRow.push(cellText);
|
||||||
|
this.cellParts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRow(): void {
|
||||||
|
if (this.currentRow.length === 0) return;
|
||||||
|
const line = '| ' + this.currentRow.join(' | ') + ' |';
|
||||||
|
this.emit(line + '\n');
|
||||||
|
if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
|
||||||
|
const sep = '| ' + this.currentRow.map(() => '---').join(' | ') + ' |';
|
||||||
|
this.emit(sep + '\n');
|
||||||
|
this.headerRowDone = true;
|
||||||
|
}
|
||||||
|
this.isFirstRow = false;
|
||||||
|
this.currentRow = [];
|
||||||
|
this.rowHasTh = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishLink(): void {
|
||||||
|
const text = this.linkTextParts.join('').replace(/\s+/g, ' ').trim();
|
||||||
|
const href = this.linkHref ?? '';
|
||||||
|
this.inLink = false;
|
||||||
|
if (href && text) {
|
||||||
|
this.emit(`[${text}](${href})`);
|
||||||
|
} else if (text) {
|
||||||
|
this.emit(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAttr(el: Element, name: string): string | undefined {
|
||||||
|
return el.attrs.find(a => a.name === name)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOpen(el: Element): void {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (HEADING_TAGS.has(tag)) {
|
||||||
|
const level = parseInt(tag[1]!, 10);
|
||||||
|
this.emit('\n\n' + '#'.repeat(level) + ' ');
|
||||||
|
} else if (tag === 'a') {
|
||||||
|
this.linkHref = this.getAttr(el, 'href') ?? null;
|
||||||
|
this.linkTextParts = [];
|
||||||
|
this.inLink = true;
|
||||||
|
} else if (tag in INLINE_EMPHASIS) {
|
||||||
|
this.emit(INLINE_EMPHASIS[tag]!);
|
||||||
|
} else if (tag === 'br') {
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (BLOCK_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'hr') {
|
||||||
|
this.emit('\n\n---\n\n');
|
||||||
|
} else if (tag === 'blockquote') {
|
||||||
|
this.emit('\n\n');
|
||||||
|
this.bqStack.push([]);
|
||||||
|
} else if (tag === 'ul') {
|
||||||
|
this.listStack.push('ul');
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'ol') {
|
||||||
|
this.listStack.push('ol');
|
||||||
|
const startAttr = this.getAttr(el, 'start');
|
||||||
|
let start = 1;
|
||||||
|
if (startAttr != null) {
|
||||||
|
const parsed = parseInt(startAttr, 10);
|
||||||
|
if (!isNaN(parsed)) start = parsed;
|
||||||
|
}
|
||||||
|
this.olCounter.push(start - 1);
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'li') {
|
||||||
|
const indent = ' '.repeat(Math.max(0, this.listStack.length - 1));
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||||
|
if (this.olCounter.length > 0) {
|
||||||
|
this.olCounter[this.olCounter.length - 1]!++;
|
||||||
|
this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
|
||||||
|
} else {
|
||||||
|
this.emit(`\n${indent}1. `);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.emit(`\n${indent}* `);
|
||||||
|
}
|
||||||
|
} else if (tag === 'pre') {
|
||||||
|
this.preParts = [];
|
||||||
|
this.inPre = true;
|
||||||
|
this.preLanguage = null;
|
||||||
|
const codeChild = el.childNodes.find(
|
||||||
|
(c): c is Element => isElement(c) && c.tagName === 'code'
|
||||||
|
);
|
||||||
|
if (codeChild) {
|
||||||
|
const cls = this.getAttr(codeChild, 'class') ?? '';
|
||||||
|
const langMatch = cls.match(/(?:^|\s)language-(\S+)/);
|
||||||
|
if (langMatch) this.preLanguage = langMatch[1]!;
|
||||||
|
}
|
||||||
|
} else if (tag === 'code' && !this.inPre) {
|
||||||
|
this.inInlineCode = true;
|
||||||
|
this.emit('`');
|
||||||
|
} else if (tag === 'table') {
|
||||||
|
this.inTable = true;
|
||||||
|
this.headerRowDone = false;
|
||||||
|
this.isFirstRow = true;
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'tr') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
} else if (tag === 'th' || tag === 'td') {
|
||||||
|
this.finishCell();
|
||||||
|
this.cellParts = [];
|
||||||
|
this.inCell = true;
|
||||||
|
if (tag === 'th') this.rowHasTh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose(tag: string): void {
|
||||||
|
tag = tag.toLowerCase();
|
||||||
|
|
||||||
|
if (HEADING_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'a') {
|
||||||
|
this.finishLink();
|
||||||
|
} else if (tag in INLINE_EMPHASIS) {
|
||||||
|
this.emit(INLINE_EMPHASIS[tag]!);
|
||||||
|
} else if (BLOCK_TAGS.has(tag)) {
|
||||||
|
this.emit('\n\n');
|
||||||
|
} else if (tag === 'blockquote') {
|
||||||
|
if (this.bqStack.length > 0) {
|
||||||
|
const content = this.bqStack.pop()!.join('');
|
||||||
|
const prefixed = this.prefixBlockquote(content);
|
||||||
|
if (prefixed) this.emit('\n\n' + prefixed + '\n\n');
|
||||||
|
}
|
||||||
|
} else if (tag === 'ul') {
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ul') {
|
||||||
|
this.listStack.pop();
|
||||||
|
}
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'ol') {
|
||||||
|
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||||
|
this.listStack.pop();
|
||||||
|
if (this.olCounter.length > 0) this.olCounter.pop();
|
||||||
|
}
|
||||||
|
this.emit('\n');
|
||||||
|
} else if (tag === 'pre') {
|
||||||
|
const raw = this.preParts.join('');
|
||||||
|
this.inPre = false;
|
||||||
|
const lang = this.preLanguage ?? '';
|
||||||
|
const block = '```' + lang + '\n' + raw + '\n```';
|
||||||
|
this.emit('\n\n' + block + '\n\n');
|
||||||
|
this.preLanguage = null;
|
||||||
|
} else if (tag === 'code' && !this.inPre) {
|
||||||
|
this.inInlineCode = false;
|
||||||
|
this.emit('`');
|
||||||
|
} else if (tag === 'th' || tag === 'td') {
|
||||||
|
this.finishCell();
|
||||||
|
} else if (tag === 'tr') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
} else if (tag === 'table') {
|
||||||
|
this.finishCell();
|
||||||
|
this.finishRow();
|
||||||
|
this.inTable = false;
|
||||||
|
this.emit('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleText(data: string): void {
|
||||||
|
if (this.inPre) {
|
||||||
|
this.preParts.push(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.inInlineCode) {
|
||||||
|
this.emit(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = data.replace(/\s+/g, ' ');
|
||||||
|
if (this.inTable && !this.inCell && !text.trim()) return;
|
||||||
|
this.emit(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(node: ChildNode | Document): void {
|
||||||
|
if (isText(node as ChildNode)) {
|
||||||
|
this.handleText((node as TextNode).value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeName === '#comment') return;
|
||||||
|
|
||||||
|
if (isElement(node as ChildNode)) {
|
||||||
|
const el = node as Element;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (SKIP_TAGS.has(tag)) return;
|
||||||
|
if (tag === 'img') return;
|
||||||
|
|
||||||
|
this.handleOpen(el);
|
||||||
|
|
||||||
|
if (tag === 'pre') {
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
if (isElement(child) && child.tagName === 'code') {
|
||||||
|
for (const grandchild of child.childNodes) {
|
||||||
|
this.walk(grandchild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleClose(tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('childNodes' in node) {
|
||||||
|
for (const child of (node as Document).childNodes) {
|
||||||
|
this.walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput(): string {
|
||||||
|
return this.out.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(text: string): string {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
let inFence = false;
|
||||||
|
let blankRun = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const stripped = line.replace(/[ \t]+$/, '');
|
||||||
|
if (stripped.startsWith('```')) {
|
||||||
|
inFence = !inFence;
|
||||||
|
blankRun = 0;
|
||||||
|
out.push(stripped);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stripped) {
|
||||||
|
blankRun++;
|
||||||
|
if (blankRun <= 1) out.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blankRun = 0;
|
||||||
|
out.push(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlToMarkdown(sourceHtml: string): string {
|
||||||
|
sourceHtml = sourceHtml.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const doc = parse(sourceHtml);
|
||||||
|
const renderer = new MarkdownRenderer();
|
||||||
|
renderer.walk(doc);
|
||||||
|
return cleanup(renderer.getOutput());
|
||||||
|
}
|
||||||
1
apps/server/src/services/web/index.ts
Normal file
1
apps/server/src/services/web/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { htmlToMarkdown } from './html-to-md.js';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user