Compare commits

...

11 Commits

Author SHA1 Message Date
c4ee377dbc feat(conductor): task state machine — TIMED_OUT state and retriable steps
- Add 'timed_out' to flow_runs/flow_steps CHECK constraints
- Add retry_count and max_retries columns to flow_steps
- Add timeout detection in advanceInner loop (configurable FLOW_STEP_TIMEOUT_MS)
- Add retriable logic: re-dispatch on timeout if maxRetries > 0 and retryCount < maxRetries
- Add isRetriable() + shouldRetry() pure decision functions
- Add timed_out handling to reconcileResumeStep and reconcileRun
- Add 'timed_out' to ws-frames enum, publishStep status type
2026-06-08 02:43:45 +00:00
f2401352a8 chore: update pnpm-lock.yaml for @ai-sdk/deepseek 2026-06-08 02:28:32 +00:00
abe9c5a3a8 feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop
Phase 1: Trace System + Observability
- tool_traces DB table + insert/update service
- tool_trace_start/tool_trace_finish WS frames (contracts + FE types)
- Instrumented tool-phase.ts with timing around every tool call
- GET /api/chats/:id/traces paginated endpoint
- Trace viewer frontend (collapsible panel with timing bars + token breakdown)

Phase 2: Session Persistence + Resume
- agent_snapshots table (UPSERT per chat, persisted on turn boundaries)
- save/load/delete service functions
- Agent snapshot sent on WS reconnect
- Session timeline view (vertical timeline with scroll-to + restore)

Tooling:
- run_command tool (execFile, 30s timeout, 32KB cap, path-guarded)
- Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
2026-06-08 02:26:47 +00:00
7cb692d8be feat: Phase 4 teardown — remove Go codecontext sidecar from deployment
- Remove codecontext service block from docker-compose.yml
- Remove CODECONTEXT_URL env var
- Delete codecontext/Dockerfile
- Update callCodecontext() to try boocontext MCP first with HTTP fallback
- Graceful degradation: if boocontext MCP unavailable, tools still work via HTTP
2026-06-08 02:16:02 +00:00
917a229363 feat: Domain 2 Phase 3-4 — wiki article tool, DCP compress toggle, Go sidecar deprecation
Phase 3: get_wiki_article tool wraps codesight_get_wiki_article MCP
(cached, persistent codebase wiki). DCP compress toggle on
get_codebase_overview (compress=true for large projects >50 files).

Phase 4: Deprecation markers on Go codecontext sidecar. Warning log
in callCodecontext(), deprecation comments in factory.ts and
docker-compose.yml. Sidecar remains functional — removal deferred.
2026-06-08 01:35:40 +00:00
39be5ce413 fix: move cache_tokens/reasoning_tokens ALTER TABLE before view creation 2026-06-08 01:32:25 +00:00
378e29308e fix: add cache_tokens/reasoning_tokens to Message constructors in useSessionStream 2026-06-08 01:27:31 +00:00
8f6a814ab0 fix: add cache_tokens/reasoning_tokens to web WsFrame union 2026-06-08 01:26:01 +00:00
3c019a2281 changelog: v2.8.18-deepseek-whale-lift 2026-06-08 01:24:59 +00:00
203cfd2fa8 feat: DeepSeek API integration + Whale lift (hooks, tool repair, MCP permissions, token tracking)
DeepSeek API:
- @ai-sdk/deepseek provider replaces openai-compatible for deepseek-* models
- Token tracking: cache_hit/reasoning tokens flow API → DB → WS frames → UI
- thinking effort levels (off/low/medium/high/xhigh/max) via AGENTS.md frontmatter
- V4 models: deepseek-v4-flash, deepseek-v4-pro
- Wired for both chat and coder panes

Whale lifts:
- Tool input repair (schema-based type coercion, markdown link unwrapping)
- Hooks system (6 lifecycle events, shell exec, JSON stdin/stdout contract)
- Per-MCP-server permissions (allow/ask/deny)
- token tracking UI (cache N, think N in message stats line)

Infra:
- New DB columns: messages.cache_tokens, messages.reasoning_tokens
- New WS frame fields: cache_tokens, reasoning_tokens on message_complete
- coder provider snapshot merges DeepSeek models alongside llama-swap
2026-06-08 01:24:23 +00:00
c11e26090f feat(coder): boulder state — cross-session plan persistence + auto-resumption
New plans table (id, project_id, title, description, status, flow_run_id,
progress_pct, items_total, items_completed, metadata, timestamps) with
CHECK constraints and indexes.

Plan store (plan-store.ts): createPlan, getPlan, listPlans, listActivePlans,
updatePlan, updatePlanFromRun, findPlanWithRunningRun, planStatusFromRun.

Flow-runner integration: onRunTerminal callback fires on every terminal
transition (complete/fail/cancel) and updates linked plans automatically.

5 API endpoints: GET /api/plans, GET /api/plans/active, GET /api/plans/:id,
POST /api/plans, PATCH /api/plans/:id.

484 tests pass, build clean.
2026-06-08 01:11:07 +00:00
62 changed files with 3829 additions and 243 deletions

View File

@@ -20,6 +20,12 @@ SEARXNG_URL=http://100.114.205.53:8888
# with FAST_MODEL when unset.
# TASK_MODEL_URL=http://100.90.172.55:7995
# DeepSeek API key. When set, models with IDs starting with 'deepseek-'
# (e.g. deepseek-chat, deepseek-reasoner, deepseek-v4-flash) route through
# DeepSeek's API instead of llama-swap. Requires a DeepSeek Platform API key.
# DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# 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
# sessions where the model only needs read-only filesystem access.

View File

@@ -0,0 +1,239 @@
# Paseo-like Orchestrator — Implementation Plan
> **Goal:** Transform BooCode into a Paseo-style thin-client orchestration layer with observability, dynamic workflows, resumability, background subagents, multi-modal, and cache shape telemetry.
>
> **Architecture:** Durable agent execution engine beneath thin chat/coder frontends. Trace system as foundation, workflow engine as the structural addition, everything else layered on top.
>
> **Inspired by:** Paseo (agent lifecycle, worktree isolation), Whale (workflow engine, cache telemetry), OpenCode (session resume), Claude Code (workflow script format).
---
## TL;DR
> **Quick Summary**: Build a durable orchestration layer with trace observability, dynamic JS workflows, session persistence, background subagents, and multi-modal support over 5 phases.
>
> **Deliverables**:
> - Trace system with DB persistence + viewer UI
> - Dynamic workflow engine (JS sandbox, agent/parallel/pipeline)
> - Workflow resumability (hash-based step caching)
> - Background subagent runtime
> - Session persistence across refreshes
> - Cache shape telemetry (DeepSeek KV cache viz)
> - Multi-modal attachment support
>
> **Estimated Effort**: XL — 5 phases, ~2-3 weeks total
> **Parallel Execution**: YES — phases 1-2 can partially overlap
> **Critical Path**: Trace system → Workflow engine → All downstream features
---
## Context
### Original Request
User wants BooCode to become "like Paseo — a thin client" with observability, dynamic workflows, session persistence, background agents, multi-modal, cache shape telemetry, and workflow resumability. They invoked skills across model evaluation, long context, SGLang, LangChain, LangSmith, agentic eval, agent harness construction, agent governance, and chat SDKs — indicating broad ambition for a production-quality AI coding platform.
### Key Decisions
- **Trace system first**: Foundation for all debugging and optimization
- **isolated-vm for workflow sandbox**: Node-native, no external deps
- **DB-backed sessions**: Postgres for trace store + session state
- **Existing WS frames + new `tool_trace` frame**: Live streaming to frontend
- **Phase ordering**: Foundation (trace) → UX (persistence) → Power (workflows) → Polish (background/multi-modal/cache)
---
## Phases
### Phase 1: Trace System + Observability
**Est. effort**: 3-4 days
Core observability infrastructure. Every tool call gets timed, logged, and persisted.
**Deliverables**:
- `tool_traces` DB table (id, session_id, chat_id, turn_number, tool_name, input, output, started_at, finished_at, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error, outcome)
- Instrumentation in `tool-phase.ts` wrapping `executeToolCall` with start/end timing
- `tool_trace` WS frame type for live streaming to frontend
- GET `/api/chats/:id/traces` endpoint (paginated)
- Trace viewer pane (collapsible tree, timing bars, expand/collapse per call)
**Files to create**: 5-7 files across server + web + contracts
**Dependencies**: None — standalone feature
---
### Phase 2: Session Persistence + Resume
**Est. effort**: 2-3 days
Agent state survives browser refresh. Active sessions can be resumed.
**Deliverables**:
- Serialize active agent state to DB on each turn boundary
- Restore state on WS reconnect (existing `snapshot` frame enhanced)
- Agent session timeline view (history of all turns in a session)
- Coder pane rehydrates from persisted state
**Files to modify**: ws.ts, useSessionStream.ts, session store, dispatcher
**Dependencies**: None — standalone, but benefits from Phase 1 trace data
---
### Phase 3: Dynamic Workflow Engine
**Est. effort**: 5-7 days
JS sandbox for multi-agent orchestration. Claude Code compatible.
**Deliverables**:
- `isolated-vm` sandbox (or Node `vm` module with restricted context)
- Workflow API: `agent()`, `parallel()`, `pipeline()`, `phase()`, `budget()`, `log()`, `args`
- Workflow file discovery (`.boocode/workflows/*.js` → project, `~/.boocode/workflows/*.js` → global)
- Built-in workflow catalog (deep-research, multi-review, etc.)
- Workflow manager with concurrency limits, token budgets
- Integration with existing Orchestrator panel for UI
**Files to create**: 10-15 files (workflow runtime, scheduler, tool bridge, manager, catalog)
**Dependencies**: Phase 1 traces feed into workflow observability
**Workflow Resumability** (within Phase 3):
- SHA-256 hash of agent spec (prompt + options)
- Cache completed results by hash
- On re-run, skip cached agents, only execute new/changed ones
- In-memory cache for current session, optional DB persistence
**Est. effort**: 1-2 days within Phase 3
---
### Phase 4: Background Subagents
**Est. effort**: 2-3 days
Non-blocking subagent execution. `spawn_subagent` returns immediately, results collected later.
**Deliverables**:
- Background task queue (reuses existing `tasks` table)
- `spawn_subagent` tool that creates a task and returns immediately
- `subagent_status` tool to poll completion
- `subagent_result` tool to retrieve output
- Background agent pane showing running/completed subagents
- Notifications via hooks when background tasks complete
**Files to create**: 3-5 files across server + web
**Dependencies**: Phase 1 traces, Phase 2 session persistence
---
### Phase 5: Multi-modal + Cache Shape (Polish)
**Est. effort**: 2-3 days
Image/file attachment support + DeepSeek cache hit visualization.
**Deliverables (Multi-modal)**:
- Image/file attachment storage (tmpfs, referenced in message)
- Forward image content through DeepSeek API's multimodal support
- Render attached images in message bubble
- Model can "see" screenshots, diagrams, UI mocks
**Deliverables (Cache Shape)**:
- Extract `prompt_cache_hit_tokens` from DeepSeek provider metadata
- Build cache segment visualization (system prompt, tool schema, conversation)
- Per-turn cache hit rate in trace viewer
- Cumulative cache stats in session view
**Files to create**: 3-5 files
**Dependencies**: Phase 1 traces (for cache shape), existing DeepSeek integration
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Start Immediately):
├── Phase 1: Trace system backend (tool_traces table + instrumentation) [deep]
├── Phase 1: Trace viewer frontend [visual-engineering]
└── Phase 2: Session persistence backbone [deep]
Wave 2 (After Wave 1):
├── Phase 3: Workflow engine sandbox + API surface [deep]
├── Phase 3: Workflow file discovery + manager [unspecified-high]
├── Phase 3: Workflow resumability cache [quick]
└── Phase 4: Background subagent queue + tools [unspecified-high]
Wave 3 (After Wave 2):
├── Phase 4: Background agent pane + notifications [visual-engineering]
├── Phase 5: Multi-modal attachment pipeline [deep]
└── Phase 5: Cache shape telemetry UI [visual-engineering]
Wave FINAL:
├── F1: Plan compliance audit (oracle)
├── F2: Code quality review (unspecified-high)
├── F3: Integration QA (unspecified-high)
└── F4: Scope fidelity check (deep)
```
---
## TODOs
> Phase 1: Trace System + Observability
- [ ] 1. Create tool_traces DB table + migration
- [ ] 2. Add tool_trace WS frame + contracts schema
- [ ] 3. Instrument tool-phase.ts with start/end timing
- [ ] 4. Add GET /api/chats/:id/traces endpoint
- [ ] 5. Build trace viewer frontend component
> Phase 2: Session Persistence + Resume
- [ ] 6. Serialize agent state to DB on turn boundaries
- [ ] 7. Restore state on WS reconnect
- [ ] 8. Agent session timeline view
> Phase 3: Dynamic Workflow Engine
- [ ] 9. Create isolated-vm workflow sandbox
- [ ] 10. Implement agent/parallel/pipeline primitives
- [ ] 11. Workflow file discovery system
- [ ] 12. Workflow manager + built-in catalog
- [ ] 13. Workflow resumability (hash-based cache)
- [ ] 14. Workflow UI integration with Orchestrator panel
> Phase 4: Background Subagents
- [ ] 15. Background task queue + spawn_subagent tool
- [ ] 16. subagent_status + subagent_result tools
- [ ] 17. Background agent pane
> Phase 5: Multi-modal + Cache Shape
- [ ] 18. Multi-modal attachment pipeline
- [ ] 19. Image render in message bubble
- [ ] 20. Cache shape telemetry data pipeline
- [ ] 21. Cache shape visualization in trace viewer
---
## Success Criteria
- Tool trace viewer shows every call with timing bars and token costs
- Browser refresh preserves agent session state
- Workflow scripts run in isolated sandbox with agent/parallel/pipeline
- Re-running a workflow skips cached agents (hash-based)
- Background subagents run independently, results collected later
- Model can see attached images in chat
- Cache hit rate visible per-turn and cumulative

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.8.18-deepseek-whale-lift — 2026-06-08
Integrates DeepSeek API directly into BooChat and BooCoder via `@ai-sdk/deepseek`, replacing the generic `openai-compatible` wrapper. DeepSeek V4 models (`deepseek-v4-flash`, `deepseek-v4-pro`) with configurable thinking effort levels appear in both chat and coder pane model pickers. Full token tracking — cache hit tokens and reasoning tokens — flow from the API through new DB columns and WS frames into the UI message stats line. Lifts three high-value features from the Whale codebase: a schema-based tool input repair system that coerces types and unwraps markdown autolinks before Zod validation, a shell-based lifecycle hooks system (PreToolUse, PostToolUse, Stop, PreCompact, PostCompact) with JSON stdin/stdout contract, and per-MCP-server permissions (allow/ask/deny) gating tool execution.
## v2.8.0-fork-lifts — 2026-06-07
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.

View File

@@ -42,6 +42,14 @@ export type StepKind = 'agent' | 'code' | 'approval';
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
/** Possible statuses for a flow step (persisted in flow_steps.status). */
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'timed_out';
/** Retry policy for a step that times out. */
export interface RetryConfig {
maxRetries: number;
}
export interface Step {
/** unique id within the flow; other steps depend on it by this id */
id: string;
@@ -59,6 +67,8 @@ export interface Step {
run: (ctx: StepContext) => string | Promise<string>;
/** optional guard — when it returns false the step is skipped (e.g. no repo) */
when?: (ctx: StepContext) => boolean;
/** max retries on timeout (0 or unset = no retry) */
maxRetries?: number;
}
export interface Flow {

View File

@@ -50,6 +50,11 @@ const ConfigSchema = z.object({
// only reaped after it's been untouched this long (avoids sweeping a dir mid
// ensureSessionWorktree create). 1h default.
ORPHAN_WORKTREE_GRACE_MS: z.coerce.number().int().positive().default(3_600_000),
DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
// v2.9.x: flow step timeout (default 5 min). When a 'running' step exceeds
// this duration, it is marked 'timed_out' and may be retried.
FLOW_STEP_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -29,7 +29,9 @@ import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerPlanRoutes } from './routes/plans.js';
import { registerWebSocket } from './routes/ws.js';
import { updatePlanFromRun } from './services/plan-store.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
@@ -229,8 +231,16 @@ async function main() {
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
// terminal callback can be wired in.
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
// terminal callback can be wired in. onRunTerminal updates linked plans.
const flowRunner = createFlowRunner({
sql, broker, log: app.log, config,
onRunTerminal: (runId, status) => {
updatePlanFromRun(sql, runId, status).catch((err) => {
app.log.error({ err: err instanceof Error ? err.message : String(err), runId },
'plans: updatePlanFromRun failed');
});
},
});
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
// Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
@@ -384,6 +394,7 @@ async function main() {
registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerPlanRoutes(app, sql);
registerWebSocket(app, sql, broker);
// Graceful shutdown

View File

@@ -0,0 +1,134 @@
/**
* Boulder state — plan routes.
*
* GET /api/plans?project_id= — list plans for a project
* GET /api/plans/active?project_id= — list active (in-flight) plans
* POST /api/plans — create a new plan
* PATCH /api/plans/:id — update plan progress / status
*/
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
createPlan,
getPlan,
listPlans,
listActivePlans,
updatePlan,
} from '../services/plan-store.js';
const CreatePlanBody = z.object({
project_id: z.string().uuid(),
title: z.string().min(1).max(500),
description: z.string().max(10_000).optional(),
flow_run_id: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
});
const ListPlansQuery = z.object({
project_id: z.string().uuid(),
});
const UpdatePlanBody = z.object({
title: z.string().min(1).max(500).optional(),
description: z.string().max(10_000).nullable().optional(),
status: z.enum(['active', 'completed', 'cancelled', 'failed']).optional(),
progress_pct: z.number().int().min(0).max(100).optional(),
items_total: z.number().int().min(0).optional(),
items_completed: z.number().int().min(0).optional(),
metadata: z.record(z.unknown()).nullable().optional(),
});
const PlanIdParam = z.string().uuid();
export function registerPlanRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/plans?project_id= — all plans for a project
app.get('/api/plans', async (req, reply) => {
const parsed = ListPlansQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const plans = await listPlans(sql, parsed.data.project_id);
return { plans };
});
// GET /api/plans/active?project_id= — active plans only
app.get('/api/plans/active', async (req, reply) => {
const parsed = ListPlansQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const plans = await listActivePlans(sql, parsed.data.project_id);
return { plans };
});
// POST /api/plans — create a new plan
app.post('/api/plans', async (req, reply) => {
const parsed = CreatePlanBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, title, description, flow_run_id, metadata } = parsed.data;
const plan = await createPlan(sql, {
projectId: project_id,
title,
description,
flowRunId: flow_run_id,
metadata,
});
reply.code(201);
return { plan };
});
// GET /api/plans/:id — single plan
app.get<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
const parsedId = PlanIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const plan = await getPlan(sql, parsedId.data);
if (!plan) {
reply.code(404);
return { error: 'plan not found' };
}
return { plan };
});
// PATCH /api/plans/:id — update plan
app.patch<{ Params: { id: string } }>('/api/plans/:id', async (req, reply) => {
const parsedId = PlanIdParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const parsed = UpdatePlanBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { title, description, status, progress_pct, items_total, items_completed, metadata } = parsed.data;
const plan = await updatePlan(sql, parsedId.data, {
title,
description: description === null ? null : description,
status,
progressPct: progress_pct,
itemsTotal: items_total,
itemsCompleted: items_completed,
metadata: metadata === null ? null : metadata,
});
if (!plan) {
reply.code(404);
return { error: 'plan not found' };
}
return { plan };
});
}

View File

@@ -266,7 +266,7 @@ CREATE INDEX IF NOT EXISTS claude_session_entries_key_idx ON claude_session_entr
-- replaces it with the three-value list).
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk'));
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk', 'paseo'));
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
@@ -340,11 +340,12 @@ CREATE INDEX IF NOT EXISTS flow_steps_task_id_idx ON flow_steps(task_id);
-- edits above are no-ops on the existing DB (CREATE TABLE IF NOT EXISTS skips an
-- existing table) — widen via the repo's DROP-IF-EXISTS → guarded-ADD discipline.
-- Pure ADD of a new allowed value, so no row UPDATE is needed (no value renamed).
-- v2.9.x: widen status CHECKs to include 'timed_out' for Task State Machine.
ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN
ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_chk
CHECK (status IN ('running', 'completed', 'failed', 'cancelled'));
CHECK (status IN ('running', 'completed', 'failed', 'cancelled', 'timed_out'));
END IF;
END $$;
@@ -352,10 +353,14 @@ ALTER TABLE flow_steps DROP CONSTRAINT IF EXISTS flow_steps_status_chk;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN
ALTER TABLE flow_steps ADD CONSTRAINT flow_steps_status_chk
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out'));
END IF;
END $$;
-- Task State Machine: retry columns for flow_steps.
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_steps ADD COLUMN IF NOT EXISTS max_retries INTEGER;
-- Arena: battles + contestants + cross_examinations.
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention).
-- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below.
@@ -438,3 +443,31 @@ CREATE TABLE IF NOT EXISTS flow_step_events (
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
-- v2.9.0: Boulder state — cross-session plan persistence with auto-resumption.
-- project_id carries no FK (matches tasks/fow_runs convention).
-- flow_run_id links the plan to an in-flight orchestrator run for auto-tracking.
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
flow_run_id UUID REFERENCES flow_runs(id) ON DELETE SET NULL,
progress_pct INTEGER NOT NULL DEFAULT 0,
items_total INTEGER NOT NULL DEFAULT 0,
items_completed INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT plans_status_chk CHECK (status IN ('active', 'completed', 'cancelled', 'failed')),
CONSTRAINT plans_progress_chk CHECK (progress_pct >= 0 AND progress_pct <= 100),
CONSTRAINT plans_items_chk CHECK (items_total >= 0 AND items_completed >= 0 AND items_completed <= items_total)
);
-- Plan queries by project and status.
CREATE INDEX IF NOT EXISTS plans_project_status_idx ON plans(project_id, status);
-- Fast lookup of the plan owning a flow run (for onRunTerminal updates).
CREATE INDEX IF NOT EXISTS plans_flow_run_id_idx ON plans(flow_run_id);
-- Plans sorted by recency (for "resume from last" surface).
CREATE INDEX IF NOT EXISTS plans_project_created_idx ON plans(project_id, created_at DESC);

View File

@@ -52,6 +52,7 @@ const emptyState = (over: Partial<SchedulerState> = {}): SchedulerState => ({
skipped: new Set(),
inFlight: new Set(),
excluded: new Set(),
timedOut: new Set(),
...over,
});

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { planStatusFromRun } from '../plan-store.js';
describe('planStatusFromRun', () => {
it('maps completed to completed', () => {
expect(planStatusFromRun('completed')).toBe('completed');
});
it('maps failed to failed', () => {
expect(planStatusFromRun('failed')).toBe('failed');
});
it('maps cancelled to cancelled', () => {
expect(planStatusFromRun('cancelled')).toBe('cancelled');
});
});

View File

@@ -33,11 +33,13 @@ export interface SchedulerState {
readonly inFlight: ReadonlySet<string>;
/** step ids pre-skipped at launch (band/when gating) — never given a row */
readonly excluded: ReadonlySet<string>;
/** step ids that timed out (terminal — no retries remaining or not retriable) */
readonly timedOut: ReadonlySet<string>;
}
/** A dependency is satisfied once it is done, skipped, or excluded. */
/** A dependency is satisfied once it is done, skipped, excluded, or timed out. */
function isSatisfied(state: SchedulerState, id: string): boolean {
return state.done.has(id) || state.skipped.has(id) || state.excluded.has(id);
return state.done.has(id) || state.skipped.has(id) || state.excluded.has(id) || state.timedOut.has(id);
}
/**
@@ -118,25 +120,50 @@ export function isStuck(flow: Flow, state: SchedulerState): boolean {
* - 'mark-cancelled': task was cancelled before the callback ran; propagate so
* advance() cancels the run.
*/
/**
* True when the step definition allows retries on timeout.
* Pure — no IO.
*/
export function isRetriable(step: { maxRetries?: number }): boolean {
return (step.maxRetries ?? 0) > 0;
}
/**
* True when the step has retries remaining.
* Pure — no IO.
*/
export function shouldRetry(maxRetries: number | undefined | null, retryCount: number): boolean {
return retryCount < (maxRetries ?? 0);
}
export type ResumeAction =
| 'keep'
| 're-dispatch'
| 'mark-done'
| 'mark-failed'
| 'mark-cancelled';
| 'mark-cancelled'
| 'retry';
/**
* Decide what to do with ONE flow step during startup resume (D-9). Pure.
*
* @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps)
* @param taskState - tasks.state for taskId, or null if the task row is absent
* @param status - flow_steps.status
* @param taskId - flow_steps.task_id (null for code steps or unstarted agent steps)
* @param taskState - tasks.state for taskId, or null if the task row is absent
* @param retryCount - flow_steps.retry_count (default 0)
* @param maxRetries - flow_steps.max_retries (null = no retry)
*/
export function reconcileResumeStep(
status: string,
taskId: string | null,
taskState: string | null,
retryCount?: number,
maxRetries?: number | null,
): ResumeAction {
if (status === 'timed_out') {
if (shouldRetry(maxRetries, retryCount ?? 0)) return 'retry';
return 'mark-failed';
}
if (status !== 'running') return 'keep';
// Running step: decide by its task's current state.
if (!taskId || taskState === null) return 're-dispatch'; // task gone or never created
@@ -198,7 +225,7 @@ export function evaluateTriggerRule(
* decision per step. Pure — no IO.
*/
export function reconcileRun(
steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string }>,
steps: ReadonlyArray<{ stepId: string; taskId: string | null; status: string; retryCount?: number; maxRetries?: number | null }>,
taskStates: ReadonlyMap<string, string>,
): StepResumeDecision[] {
return steps.map((step) => ({
@@ -207,6 +234,8 @@ export function reconcileRun(
step.status,
step.taskId,
step.taskId ? (taskStates.get(step.taskId) ?? null) : null,
step.retryCount,
step.maxRetries,
),
}));
}

View File

@@ -89,6 +89,8 @@ interface Deps {
broker: Broker;
log: FastifyBaseLogger;
config: Config;
/** Fired when a flow run reaches a terminal state (for plan-store integration). */
onRunTerminal?: (runId: string, status: 'completed' | 'failed' | 'cancelled') => void;
}
interface FlowStepRow {
@@ -98,6 +100,9 @@ interface FlowStepRow {
status: string;
chat_id: string | null;
output: string | null;
updated_at: string | null;
retry_count: number | null;
max_retries: number | null;
}
export function createFlowRunner(deps: Deps): FlowRunner {
@@ -261,7 +266,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const dispatch: DispatchFn = (agent, task) => dispatchSubAgent(run.project_id, model, agent, task);
const rows = await sql<FlowStepRow[]>`
SELECT step_id, kind, agent, status, chat_id, output FROM flow_steps WHERE run_id = ${runId}
SELECT step_id, kind, agent, status, chat_id, output, updated_at, retry_count, max_retries
FROM flow_steps WHERE run_id = ${runId}
`;
// Re-derive the excluded set (band/when pre-skips) from the flow def + input —
@@ -273,6 +279,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
const done = new Set<string>();
const skipped = new Set<string>();
const inFlight = new Set<string>();
const timedOut = new Set<string>();
const results: Record<string, string> = {};
for (const r of rows) {
switch (r.status) {
@@ -286,6 +293,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
case 'running':
inFlight.add(r.step_id);
break;
case 'timed_out':
timedOut.add(r.step_id);
break;
case 'failed':
// A failed worker makes the deterministic report untrustworthy — fail the
// whole run (matches the Phase-1 CLI, which throws on a dispatch failure).
@@ -298,10 +308,68 @@ export function createFlowRunner(deps: Deps): FlowRunner {
}
}
// ─── Timeout detection ───────────────────────────────────────────────────────
// Check running steps. If a step has been 'running' longer than
// FLOW_STEP_TIMEOUT_MS, mark it timed_out or re-dispatch if retriable.
const timeoutMs = config.FLOW_STEP_TIMEOUT_MS;
const nowDate = new Date();
let detectedTimedOut = false;
for (const r of rows) {
if (r.status !== 'running') continue;
if (!r.updated_at) continue;
const elapsed = nowDate.getTime() - new Date(r.updated_at).getTime();
if (elapsed <= timeoutMs) continue;
// Step has exceeded the timeout
detectedTimedOut = true;
const retryCount = r.retry_count ?? 0;
const maxRetries = r.max_retries ?? 0;
if (maxRetries > 0 && retryCount < maxRetries) {
// Retriable: re-dispatch the step with an incremented retry_count
const step = flow.steps.find((s) => s.id === r.step_id);
if (!step || step.kind !== 'agent') {
// Non-agent steps can't be retried via dispatch
inFlight.delete(r.step_id);
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out (non-retriable kind)`, r.step_id);
return;
}
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps
SET retry_count = ${retryCount + 1}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
await dispatchAgentStep(runId, run.project_id, model, step, ctx);
inFlight.add(r.step_id);
log.warn({ runId, stepId: r.step_id, retry: retryCount + 1, maxRetries },
'flow-runner: step timed out, retrying');
} else {
// Not retriable — mark as timed_out, fail the run
inFlight.delete(r.step_id);
await sql`
UPDATE flow_steps SET status = 'timed_out', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${r.step_id} AND status = 'running'
`;
timedOut.add(r.step_id);
publishStep(runId, r.step_id, 'timed_out');
await failRun(runId, flow, input, model,
`step '${r.step_id}' timed out`, r.step_id);
return;
}
}
// If we modified any steps, re-query so the state sets reflect the latest DB.
if (detectedTimedOut) {
// Continue with the in-memory state we already adjusted above (inFlight/timedOut
// were mutated directly). No re-query needed.
}
// Drain ready skips + code steps (synchronous), re-evaluating after each batch,
// then dispatch the full ready agent wave and wait for their terminal callbacks.
for (;;) {
const state: SchedulerState = { done, skipped, inFlight, excluded };
const state: SchedulerState = { done, skipped, inFlight, excluded, timedOut };
if (isRunComplete(flow, state)) {
await finishRun(runId, flow, input, results, model, dispatch);
@@ -479,6 +547,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE id = ${runId} AND status = 'running'
`;
if (updated.count === 0) return; // already terminal (e.g. cancelled) — don't publish
deps.onRunTerminal?.(runId, 'completed');
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
run_status: 'completed',
report,
@@ -498,6 +567,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE id = ${runId} AND status = 'running'
`;
if (updated.count === 0) return;
deps.onRunTerminal?.(runId, 'failed');
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
@@ -512,6 +582,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE id = ${runId} AND status = 'running'
`;
if (updated.count === 0) return; // idempotent — already terminal
deps.onRunTerminal?.(runId, 'cancelled');
// Any remaining pending steps are unreachable; mark + publish them so the
// pane can show them as cancelled rather than stuck in pending.
const pending = await sql<{ step_id: string; kind: string }[]>`
@@ -540,7 +611,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep(
runId: string,
stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked' | 'timed_out',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void {
publishUser({
@@ -678,6 +749,38 @@ export function createFlowRunner(deps: Deps): FlowRunner {
log.info({ runId, stepId: step.step_id, taskId: task!.id }, 'flow-runner: step re-dispatched on resume');
break;
}
case 'retry': {
// Like re-dispatch but increments retry_count and sets status to 'running'.
if (!step.input) {
await sql`
UPDATE flow_steps
SET status = 'failed', error = 'retry: no stored prompt',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
break;
}
const chatIdR = step.chat_id;
const [chatR] = chatIdR
? await sql<{ session_id: string }[]>`SELECT session_id FROM chats WHERE id = ${chatIdR}`
: [];
const sessionIdR = chatR?.session_id ?? null;
const [taskR] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, session_id, chat_id)
VALUES (${projectId}, ${step.input}, 'qwen', ${model}, 'plan', ${sessionIdR}, ${chatIdR})
RETURNING id
`;
await sql`
UPDATE flow_steps
SET task_id = ${taskR!.id}, retry_count = retry_count + 1, status = 'running',
updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.step_id}
`;
log.info({ runId, stepId: step.step_id, taskId: taskR!.id },
'flow-runner: step retried on resume');
break;
}
}
}
@@ -692,7 +795,9 @@ export function createFlowRunner(deps: Deps): FlowRunner {
status: string;
chat_id: string | null;
input: string | null;
}[]>`SELECT step_id, task_id, status, chat_id, input FROM flow_steps WHERE run_id = ${run.id}`;
retry_count: number | null;
max_retries: number | null;
}[]>`SELECT step_id, task_id, status, chat_id, input, retry_count, max_retries FROM flow_steps WHERE run_id = ${run.id}`;
// Load task states for all referenced tasks in one query.
const taskIds = rows.map((r) => r.task_id).filter((id): id is string => id !== null);
@@ -705,7 +810,13 @@ export function createFlowRunner(deps: Deps): FlowRunner {
}
const decisions = reconcileRun(
rows.map((r) => ({ stepId: r.step_id, taskId: r.task_id, status: r.status })),
rows.map((r) => ({
stepId: r.step_id,
taskId: r.task_id,
status: r.status,
retryCount: r.retry_count ?? undefined,
maxRetries: r.max_retries,
})),
taskStates,
);
@@ -742,17 +853,18 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE id = ${runId} AND status = 'running'
`;
if (updated.count === 0) return { cancelled: false, taskIds: [] };
deps.onRunTerminal?.(runId, 'cancelled');
// Mark all non-terminal steps cancelled and collect in-flight task_ids.
const steps = await sql<{ step_id: string; task_id: string | null; kind: string }[]>`
SELECT step_id, task_id, kind FROM flow_steps
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped')
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`;
if (steps.length > 0) {
await sql`
UPDATE flow_steps SET status = 'cancelled', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped')
WHERE run_id = ${runId} AND status NOT IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
`;
for (const s of steps) {
if (s.kind === 'agent') publishStep(runId, s.step_id, 'cancelled', { run_status: 'cancelled' });

View File

@@ -0,0 +1,184 @@
/**
* Boulder state — cross-session plan persistence for BooCode.
*
* Plans live above flow_runs: a plan tracks a user's work goal and can link to
* a flow run for automatic progress tracking. When the linked flow run reaches
* a terminal state (completed/failed/cancelled), the plan is auto-updated.
*
* Auto-resumption: on startup, plans with a linked in-flight flow_run are
* surfaced via the GET endpoint so the UI can show a resume prompt. The
* flow-runner's initResume() re-advances the actual run; this store surfaces
* the plan-level view.
*/
import type { Sql } from '../db.js';
export interface Plan {
id: string;
project_id: string;
title: string;
description: string | null;
status: string;
flow_run_id: string | null;
progress_pct: number;
items_total: number;
items_completed: number;
metadata: Record<string, unknown> | null;
created_at: Date;
updated_at: Date;
}
export interface CreatePlanOpts {
projectId: string;
title: string;
description?: string;
flowRunId?: string;
metadata?: Record<string, unknown>;
}
export interface UpdatePlanOpts {
title?: string;
description?: string | null;
status?: 'active' | 'completed' | 'cancelled' | 'failed';
progressPct?: number;
itemsTotal?: number;
itemsCompleted?: number;
metadata?: Record<string, unknown> | null;
}
export function createPlan(sql: Sql, opts: CreatePlanOpts): Promise<Plan> {
return sql`
INSERT INTO plans (project_id, title, description, flow_run_id, metadata)
VALUES (
${opts.projectId},
${opts.title},
${opts.description ?? null},
${opts.flowRunId ?? null},
${opts.metadata ? sql.json(opts.metadata as never) : null}
)
RETURNING *
`.then((rows) => rows[0] as unknown as Plan);
}
export function getPlan(sql: Sql, planId: string): Promise<Plan | null> {
return sql`
SELECT * FROM plans WHERE id = ${planId}
`.then((rows) => (rows[0] as unknown as Plan) ?? null);
}
export function listPlans(sql: Sql, projectId: string): Promise<Plan[]> {
return sql`
SELECT * FROM plans
WHERE project_id = ${projectId}
ORDER BY created_at DESC
LIMIT 100
` as Promise<Plan[]>;
}
export function listActivePlans(sql: Sql, projectId: string): Promise<Plan[]> {
return sql`
SELECT * FROM plans
WHERE project_id = ${projectId} AND status = 'active'
ORDER BY created_at DESC
` as Promise<Plan[]>;
}
export async function updatePlan(
sql: Sql,
planId: string,
opts: UpdatePlanOpts,
): Promise<Plan | null> {
const sets: string[] = [];
const values: unknown[] = [];
if (opts.title !== undefined) {
sets.push(`title = $${values.length + 1}`);
values.push(opts.title);
}
if (opts.description !== undefined) {
sets.push(`description = $${values.length + 1}`);
values.push(opts.description);
}
if (opts.status !== undefined) {
sets.push(`status = $${values.length + 1}`);
values.push(opts.status);
}
if (opts.progressPct !== undefined) {
sets.push(`progress_pct = $${values.length + 1}`);
values.push(opts.progressPct);
}
if (opts.itemsTotal !== undefined) {
sets.push(`items_total = $${values.length + 1}`);
values.push(opts.itemsTotal);
}
if (opts.itemsCompleted !== undefined) {
sets.push(`items_completed = $${values.length + 1}`);
values.push(opts.itemsCompleted);
}
if (opts.metadata !== undefined) {
sets.push(`metadata = $${values.length + 1}::jsonb`);
values.push(opts.metadata !== null ? JSON.stringify(opts.metadata) : null);
}
if (sets.length === 0) return getPlan(sql, planId);
sets.push(`updated_at = clock_timestamp()`);
const query = `
UPDATE plans SET ${sets.join(', ')}
WHERE id = $${values.length + 1}
RETURNING *
`;
values.push(planId);
const result = await sql.unsafe(query, values as never[]);
return (result[0] as unknown as Plan) ?? null;
}
/**
* Called when a flow run reaches a terminal state. Updates the linked plan's
* status based on the run outcome:
* - completed → plan completed
* - failed → plan failed
* - cancelled → plan cancelled
* Returns true when a plan was updated, false when no plan is linked to the run.
*/
export async function updatePlanFromRun(
sql: Sql,
runId: string,
runStatus: 'completed' | 'failed' | 'cancelled',
): Promise<boolean> {
const planStatus = planStatusFromRun(runStatus);
const updated = await sql`
UPDATE plans
SET status = ${planStatus}, progress_pct = 100,
items_completed = items_total, updated_at = clock_timestamp()
WHERE flow_run_id = ${runId} AND status = 'active'
`;
return updated.count > 0;
}
/** Map a flow-run terminal status to its corresponding plan status. Pure. */
export function planStatusFromRun(runStatus: 'completed' | 'failed' | 'cancelled'): string {
return runStatus === 'completed' ? 'completed' : runStatus;
}
/**
* Find any active plan linked to a running flow run — used by the startup
* resume path to surface plans that have in-flight orchestrator runs.
*/
export async function findPlanWithRunningRun(
sql: Sql,
projectId: string,
): Promise<(Plan & { run_status: string }) | null> {
const [row] = await sql`
SELECT p.*, fr.status AS run_status
FROM plans p
JOIN flow_runs fr ON fr.id = p.flow_run_id
WHERE p.project_id = ${projectId}
AND p.status = 'active'
AND fr.status = 'running'
ORDER BY p.created_at DESC
LIMIT 1
`;
return (row as unknown as Plan & { run_status: string }) ?? null;
}

View File

@@ -29,6 +29,22 @@ interface AgentRow {
last_probed_at: string | Date | null;
}
export async function fetchDeepSeekModels(config: Config): Promise<ProviderModel[]> {
if (!config.DEEPSEEK_API_KEY) return [];
try {
const baseURL = (config.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, '');
const res = await fetch(`${baseURL}/v1/models`, {
headers: { Authorization: `Bearer ${config.DEEPSEEK_API_KEY}` },
signal: AbortSignal.timeout(5_000),
});
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 [];
}
}
export async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
@@ -256,7 +272,13 @@ export async function getProviderSnapshot(
}
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config);
const [llamaModels, deepseekModels] = await Promise.all([
fetchLlamaSwapModels(config),
fetchDeepSeekModels(config),
]);
// Merge DeepSeek models into the llama-swap model pool so the boocode
// provider (which sources from llama-swap) also includes DeepSeek models.
const mergedModels = mergeModels(llamaModels, deepseekModels);
const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
`;
@@ -265,7 +287,7 @@ export async function getProviderSnapshot(
const entries = await Promise.all(
[...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
buildProviderEntry(resolved, agentMap.get(resolved.id), mergedModels, resolvedCwd, ttlMs, force),
),
);

View File

@@ -77,8 +77,9 @@
"test": "vitest run"
},
"dependencies": {
"@boocode/contracts": "workspace:*",
"@ai-sdk/deepseek": "^2.0.35",
"@ai-sdk/openai-compatible": "^2.0.47",
"@boocode/contracts": "workspace:*",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",

View File

@@ -26,6 +26,14 @@ const ConfigSchema = z.object({
FAST_MODEL: z.string().optional(),
TASK_MODEL_URL: z.string().url().optional(),
LLAMA_SIDECAR_URL: z.string().url().optional(),
// vDeepSeek: DeepSeek API key for direct API access. When set, models
// with IDs starting with 'deepseek-' route through DeepSeek's API instead
// of llama-swap. Defaults to empty (DeepSeek routing disabled).
DEEPSEEK_API_KEY: z.string().optional(),
// Optional base URL override for DeepSeek API. Defaults to api.deepseek.com.
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
// vWhale hooks: path to hooks JSON config file. Missing file = no hooks.
HOOKS_CONFIG_PATH: z.string().default('/data/hooks.json'),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -18,8 +18,10 @@ import { registerCoderProxy } from './routes/coder-proxy.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { registerTraceRoutes } from './routes/traces.js';
import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerMemoryRoutes } from './routes/memory.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js';
@@ -31,6 +33,7 @@ import { loadMcpConfig } from './services/mcp-config.js';
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
import { appendMcpTools } from './services/tools.js';
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
import { loadHooksConfig, createHookRunner } from './services/hooks.js';
async function main() {
const config = loadConfig();
@@ -123,8 +126,10 @@ async function main() {
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerTraceRoutes(app, sql);
registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerMemoryRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
@@ -136,11 +141,17 @@ async function main() {
app.log.warn({ err }, 'skills boot walk failed');
}
// vWhale hooks: load hook config and create runner. Missing file = no hooks.
loadHooksConfig(config.HOOKS_CONFIG_PATH);
const hookRunner = createHookRunner();
const hasHooks = Object.keys(loadHooksConfig(config.HOOKS_CONFIG_PATH).hooks).length > 0;
const inference = createInferenceRunner(
{
sql,
config,
log: app.log,
hooks: hasHooks ? hookRunner : undefined,
publish: (sessionId, frame) => {
// v1.13.11-b: route through the typed publishFrame so the broker's
// Zod gate validates every inference frame before delivery.
@@ -166,7 +177,7 @@ async function main() {
// bubble up so the route can reply 500 — manual /compact failures
// should be loud (the user just clicked a button).
runCompaction: (chatId) =>
compaction.process({ sql, config, log: app.log, broker, chatId }),
compaction.process({ sql, config, log: app.log, broker, chatId, hooks: hasHooks ? hookRunner : undefined }),
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},

View File

@@ -2,26 +2,55 @@ import type { FastifyInstance } from 'fastify';
import type { Config } from '../config.js';
import type { ModelInfo } from '../types/api.js';
interface LlamaSwapModelsResponse {
interface ApiModelsResponse {
data?: ModelInfo[];
}
const DEEPSEEK_STATIC_MODELS: ModelInfo[] = [
{ id: 'deepseek-v4-flash', object: 'model', created: 0, owned_by: 'deepseek' },
{ id: 'deepseek-v4-pro', object: 'model', created: 0, owned_by: 'deepseek' },
];
export function registerModelRoutes(app: FastifyInstance, config: Config): void {
app.get('/api/models', async (_req, reply) => {
const models: ModelInfo[] = [];
// 1. Fetch llama-swap models
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) {
reply.code(502);
return { error: `llama-swap returned ${res.status}` };
if (res.ok) {
const parsed = (await res.json()) as ApiModelsResponse;
if (parsed.data) models.push(...parsed.data);
}
const parsed = (await res.json()) as LlamaSwapModelsResponse;
return parsed.data ?? [];
} catch (err) {
reply.code(502);
return {
error: 'failed to reach llama-swap',
details: err instanceof Error ? err.message : String(err),
};
} catch {
// llama-swap unreachable — proceed with whatever we have
}
// 2. If DeepSeek is configured, fetch live models from their API
if (config.DEEPSEEK_API_KEY) {
try {
const baseURL = (config.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, '');
const res = await fetch(`${baseURL}/v1/models`, {
headers: { Authorization: `Bearer ${config.DEEPSEEK_API_KEY}` },
signal: AbortSignal.timeout(5_000),
});
if (res.ok) {
const parsed = (await res.json()) as ApiModelsResponse;
if (parsed.data) models.push(...parsed.data);
} else {
// API call failed — fall back to static model list
models.push(...DEEPSEEK_STATIC_MODELS);
}
} catch {
// Network error — fall back to static model list
models.push(...DEEPSEEK_STATIC_MODELS);
}
}
if (models.length === 0) {
reply.code(502);
return { error: 'no models available from any provider' };
}
return models;
});
}

View File

@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { ToolTrace } from '../services/tool-traces.js';
export function registerTraceRoutes(app: FastifyInstance, sql: Sql): void {
app.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
'/api/chats/:id/traces',
async (req, reply) => {
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const rows = await sql<ToolTrace[]>`
SELECT * FROM tool_traces
WHERE chat_id = ${req.params.id}
ORDER BY started_at ASC
LIMIT ${limit}
OFFSET ${offset}
`;
const [countRow] = await sql<{ count: number }[]>`
SELECT count(*)::int AS count FROM tool_traces WHERE chat_id = ${req.params.id}
`;
return {
data: rows,
total: countRow?.count ?? 0,
limit,
offset,
};
},
);
}

View File

@@ -3,6 +3,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js';
import type { Message } from '../types/api.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
import { loadAgentSnapshot } from '../services/session-snapshots.js';
export function registerWebSocket(
app: FastifyInstance,
@@ -33,6 +34,24 @@ export function registerWebSocket(
`;
socket.send(JSON.stringify({ type: 'snapshot', messages }));
// v2.7.x: on reconnect, restore agent snapshot state so the frontend
// knows there's an ongoing agent turn. Best-effort per chat; most
// sessions won't have any snapshots.
const chats = await sql<{ id: string }[]>`SELECT id FROM chats WHERE session_id = ${sessionId}`;
for (const chat of chats) {
const agentSnapshot = await loadAgentSnapshot(sql, chat.id).catch(() => null);
if (agentSnapshot) {
socket.send(JSON.stringify({
type: 'agent_snapshot',
chat_id: chat.id,
agent: agentSnapshot.agent,
model: agentSnapshot.model,
mode: agentSnapshot.mode,
turn_number: agentSnapshot.turn_number,
}));
}
}
const unsubscribe = broker.subscribe(sessionId, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {

View File

@@ -32,11 +32,18 @@ CREATE TABLE IF NOT EXISTS messages (
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0,
cache_tokens INTEGER,
reasoning_tokens INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
-- vDeepSeek: add cache/reasoning token columns early so messages_with_parts
-- view (defined below) can reference them. IF NOT EXISTS guards re-runs.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS cache_tokens INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reasoning_tokens INTEGER;
-- v1.13.0: granular message parts table. v1.13.20: legacy tool_calls/
-- tool_results columns dropped; message_parts is now the sole source of
-- truth for tool calls, tool results, and reasoning. ON DELETE CASCADE
@@ -126,8 +133,8 @@ SELECT
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts,
-- NEW columns MUST be appended at the end: CREATE OR REPLACE VIEW can't
-- reorder/rename existing columns (42P16). m.model added last.
m.model
-- reorder/rename existing columns (42P16). cache_tokens and reasoning_tokens added last.
m.model, m.cache_tokens, m.reasoning_tokens
FROM messages m;
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
@@ -407,3 +414,55 @@ END $$;
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
-- v2.x-tool-traces: per-call tool execution records for observability.
CREATE TABLE IF NOT EXISTS tool_traces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
tool_output TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
finished_at TIMESTAMPTZ,
latency_ms INTEGER,
tokens_used INTEGER,
cache_tokens INTEGER,
reasoning_tokens INTEGER,
error TEXT,
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_tool_traces_chat ON tool_traces(chat_id, created_at);
-- v2.x-tool-traces: active tool call state for in-flight instrumentation.
CREATE TABLE IF NOT EXISTS tool_trace_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
turn_number INTEGER NOT NULL,
tool_name TEXT NOT NULL,
tool_input JSONB NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- agent_snapshots: persistent agent session state for cross-refresh resume.
CREATE TABLE IF NOT EXISTS agent_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
model TEXT NOT NULL,
agent TEXT,
mode TEXT,
turn_number INTEGER NOT NULL DEFAULT 0,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
tool_states JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_chat ON agent_snapshots(chat_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_snapshots_chat_unique ON agent_snapshots(chat_id);

View File

@@ -106,6 +106,8 @@ interface ParsedFrontmatter {
// allowed" — the model responds text-only.
steps?: number;
llama_extra_args?: string[];
// vDeepSeek: thinking effort for DeepSeek V4 models.
reasoning_effort?: string;
}
// P5: table-driven validation for the "soft-range" numeric frontmatter fields.
@@ -386,6 +388,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
steps: typeof fm.steps === 'number' ? fm.steps : null,
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
reasoning_effort: typeof fm.reasoning_effort === 'string' ? (fm.reasoning_effort as Agent['reasoning_effort']) : null,
};
}

View File

@@ -1,3 +1,10 @@
// DEPRECATED (Phase 4, Domain 2, v2.8.14): This HTTP client routes through
// the Go codecontext sidecar (http://codecontext:8080). Superseded by the
// boocontext MCP server. New callers should use boocontext MCP tool wrappers
// directly. Keep this file for backward compatibility — the 16 existing
// codecontext tool wrappers (under tools/codecontext/) still call through
// callCodecontext(). Remove after full migration.
//
// v1.12 Track B.2: shared HTTP client for the codecontext sidecar. The 8
// per-tool wrappers under tools/codecontext/ all funnel through callCodecontext
// — they're thin adapters that supply toolName + args + projectPath. The
@@ -19,6 +26,7 @@
import { access, copyFile, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve, sep } from 'node:path';
import { truncateIfNeeded } from './truncate.js';
import { callBoocontext } from './boocontext_client.js';
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
// when it can't ignore them. The .codecontextignore.template ships with the
@@ -112,6 +120,16 @@ export async function callCodecontext(
req: CodecontextRequest,
fetcher: typeof fetch = fetch,
): Promise<CodecontextResponse> {
// Phase 4: try boocontext MCP first. Falls back to the HTTP sidecar if the
// MCP server is not available or the tool doesn't exist there.
try {
return await callBoocontext({ toolName: req.toolName, args: req.args });
} catch (err) {
console.warn(
`[codecontext_client] boocontext MCP unavailable for "${req.toolName}", falling back to HTTP sidecar: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Step 1: realpath the project root, then realpath the requested target_dir
// (defaulting to projectPath when the caller didn't pass one — the 12 wrappers
// never pass target_dir; tests can override). A non-existent target_dir

View File

@@ -24,6 +24,8 @@ import { SUMMARY_TEMPLATE } from './compaction-prompt.js';
import * as modelContextLookup from './model-context.js';
import { SENTINEL_KINDS } from './inference/sentinels.js';
import type { OpenAiMessage } from './inference/payload.js';
import { resolveModelEndpoint } from './inference/provider.js';
import type { HookRunner } from './hooks.js';
// v1.13.9: ratio-only overflow trigger. Fires compaction at 85% of ctx_max
// (opencode session/overflow.ts pattern). Replaces the v1.11.0-era
@@ -346,20 +348,22 @@ interface CompletionResult {
completionTokens: number;
}
async function callLlamaSwap(
async function callLlm(
config: Config,
model: string,
messages: OpenAiMessage[],
log: FastifyBaseLogger,
): Promise<CompletionResult> {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
const { url, headers, model: resolvedModel } = resolveModelEndpoint(config, model);
const res = await fetch(`${url}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false }),
headers,
body: JSON.stringify({ model: resolvedModel, messages, stream: false }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`llama-swap returned ${res.status}: ${text.slice(0, 200)}`);
const prefix = model.startsWith('deepseek-') ? 'deepseek' : 'llama-swap';
throw new Error(`${prefix} returned ${res.status}: ${text.slice(0, 200)}`);
}
const json = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
@@ -383,6 +387,8 @@ export interface ProcessInput {
log: FastifyBaseLogger;
broker: Broker;
chatId: string;
/** vWhale: lifecycle hooks runner. Undefined when no hooks configured. */
hooks?: HookRunner;
}
// Runs one round of anchored rolling compaction on `chatId`. No-ops cleanly
@@ -497,6 +503,17 @@ export async function process(input: ProcessInput): Promise<void> {
at: new Date().toISOString(),
});
// vWhale: PreCompact hook (best-effort, non-blocking).
const msgBefore = messages.length;
if (input.hooks) {
input.hooks.run('PreCompact', {
event: 'PreCompact',
session_id: sessionId,
chat_id: chatId,
messages_before: msgBefore,
}).catch(() => {});
}
// try/finally so the dot ALWAYS drops back to idle, even if the LLM call
// throws or a downstream DB write fails. The succeeded flag gates the
// 'compacted' frame + final log: we only signal completion to the UI when
@@ -506,7 +523,7 @@ export async function process(input: ProcessInput): Promise<void> {
let result: CompletionResult | undefined;
try {
// 7. Single completion (no tools). Throws on llama-swap failure.
result = await callLlamaSwap(config, session.model, payload, log);
result = await callLlm(config, session.model, payload, log);
// 7b. v1.11.3: fetch the model's true context window from llama-swap's
// /upstream/<model>/props (the streaming completion doesn't carry it).
@@ -558,6 +575,18 @@ export async function process(input: ProcessInput): Promise<void> {
`;
succeeded = true;
// vWhale: PostCompact hook (best-effort, non-blocking).
if (input.hooks) {
input.hooks.run('PostCompact', {
event: 'PostCompact',
session_id: sessionId,
chat_id: chatId,
messages_before: msgBefore,
messages_after: sel.head.length,
summary: (result?.content ?? '').slice(0, 500),
}).catch(() => {});
}
} finally {
// Always restore the dot. Status='idle' (not 'error') even on failure —
// the caller logs/re-surfaces the error separately; the dot doesn't

View File

@@ -0,0 +1,299 @@
/**
* vWhale: lifecycle hook runner. Hooks are shell commands that fire at key
* points in the inference pipeline. Each hook receives a JSON payload on
* stdin and can return JSON on stdout to influence behavior.
*
* Inspired by Whale's hook system with 11 lifecycle events. BooCode
* implements the most relevant subset: PreToolUse, PostToolUse,
* UserPromptSubmit, Stop, PreCompact, PostCompact.
*
* Config: JSON file at HOOKS_CONFIG_PATH (default /data/hooks.json).
* Format:
* ```json
* {
* "hooks": {
* "PreToolUse": [
* { "match": "shell_run", "command": "python3 /data/hooks/check_shell.py", "timeout": 30 }
* ],
* "Stop": [
* { "command": "node /data/hooks/log_turn.mjs" }
* ]
* }
* }
* ```
*/
import { spawn } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import type { FastifyBaseLogger } from 'fastify';
// ─── Events ───────────────────────────────────────────────────────────────
export type HookEvent =
| 'PreToolUse'
| 'PostToolUse'
| 'UserPromptSubmit'
| 'Stop'
| 'PreCompact'
| 'PostCompact';
const ALL_EVENTS: HookEvent[] = [
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'Stop',
'PreCompact',
'PostCompact',
];
// ─── Config ────────────────────────────────────────────────────────────────
export interface HookConfig {
/** Glob or exact tool name to match (PreToolUse/PostToolUse only). Omit or '*' for all. */
match?: string;
/** Shell command to run. Receives JSON payload on stdin. */
command: string;
/** Timeout in seconds (default 30). */
timeout?: number;
}
export interface HooksConfig {
hooks: Partial<Record<HookEvent, HookConfig[]>>;
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface PreToolUsePayload {
event: 'PreToolUse';
session_id: string;
tool_name: string;
tool_args: Record<string, unknown>;
}
export interface PostToolUsePayload {
event: 'PostToolUse';
session_id: string;
tool_name: string;
tool_args: Record<string, unknown>;
tool_result: unknown;
tool_error?: string;
}
export interface UserPromptSubmitPayload {
event: 'UserPromptSubmit';
session_id: string;
chat_id: string;
prompt: string;
}
export interface StopPayload {
event: 'Stop';
session_id: string;
chat_id: string;
last_assistant_text: string;
turn: number;
}
export interface PreCompactPayload {
event: 'PreCompact';
session_id: string;
chat_id: string;
messages_before: number;
}
export interface PostCompactPayload {
event: 'PostCompact';
session_id: string;
chat_id: string;
messages_before: number;
messages_after: number;
summary: string;
}
export type HookPayload =
| PreToolUsePayload
| PostToolUsePayload
| UserPromptSubmitPayload
| StopPayload
| PreCompactPayload
| PostCompactPayload;
// ─── Response ──────────────────────────────────────────────────────────────
export type HookDecision = 'pass' | 'warn' | 'block';
export interface HookResponse {
decision?: HookDecision;
reason?: string;
/** When present, replaces the original tool args / user prompt. */
updated_input?: Record<string, unknown> | string;
/** Injected into the model's context for the next turn. */
additional_context?: string;
}
// ─── Runner ────────────────────────────────────────────────────────────────
export interface HookRunner {
/** Run all hooks for the given event. Returns the effective response. */
run(event: HookEvent, payload: HookPayload, log?: FastifyBaseLogger): Promise<HookResponse>;
}
let hooksConfig: HooksConfig | null = null;
let hooksPath: string | null = null;
/** Load hooks config from disk. Missing file = no hooks. Never throws. */
export function loadHooksConfig(path: string): HooksConfig {
hooksPath = path;
if (!existsSync(path)) {
hooksConfig = { hooks: {} };
return hooksConfig;
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw) as HooksConfig;
hooksConfig = {
hooks: { ...parsed.hooks },
};
// Validate event names
for (const event of Object.keys(hooksConfig.hooks)) {
if (!ALL_EVENTS.includes(event as HookEvent)) {
console.warn(`hooks: unknown event '${event}' in ${path} — ignoring`);
delete hooksConfig.hooks[event as HookEvent];
}
}
} catch (err) {
console.error(`hooks: failed to load ${path}`, err);
hooksConfig = { hooks: {} };
}
return hooksConfig;
}
/** Reload the config file (call after a PATCH). */
export function reloadHooksConfig(): HooksConfig {
if (hooksPath) return loadHooksConfig(hooksPath);
hooksConfig = { hooks: {} };
return hooksConfig;
}
function getConfig(): HooksConfig {
return hooksConfig ?? { hooks: {} };
}
/** Create a HookRunner for the current config. */
export function createHookRunner(): HookRunner {
return {
async run(event, payload, log): Promise<HookResponse> {
const configs = getConfig().hooks[event];
if (!configs || configs.length === 0) return { decision: 'pass' };
// Pre-filter by match pattern for tool events
const toolName = 'tool_name' in payload ? (payload as PreToolUsePayload).tool_name : undefined;
let effective: HookResponse = { decision: 'pass' };
for (const cfg of configs) {
// Skip if match doesn't apply
if (toolName && cfg.match && cfg.match !== '*' && cfg.match !== toolName) continue;
const result = await runSingleHook(cfg, payload, log);
// Merge decisions: block > warn > pass
if (result.decision === 'block') {
effective = { ...result, decision: 'block' };
break; // block is terminal
}
if (result.decision === 'warn' && effective.decision !== 'block') {
effective = { ...result, decision: 'warn' };
}
// Merge additional_context and updated_input
if (result.additional_context) {
effective.additional_context = effective.additional_context
? effective.additional_context + '\n' + result.additional_context
: result.additional_context;
}
if (result.updated_input && !effective.updated_input) {
effective.updated_input = result.updated_input;
}
}
return effective;
},
};
}
async function runSingleHook(
cfg: HookConfig,
payload: HookPayload,
log?: FastifyBaseLogger,
): Promise<HookResponse> {
const timeoutMs = (cfg.timeout ?? 30) * 1000;
return new Promise((resolve) => {
const child = spawn('sh', ['-c', cfg.command], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: timeoutMs,
env: { ...process.env },
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
child.kill('SIGTERM');
log?.warn({ event: payload.event, command: cfg.command }, 'hooks: timeout');
resolve({ decision: 'warn', reason: 'hook timed out' });
}
}, timeoutMs);
child.on('error', (err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
log?.warn({ err, event: payload.event }, 'hooks: spawn error');
resolve({ decision: 'warn', reason: `hook failed: ${err.message}` });
}
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const out = Buffer.concat(stdout).toString('utf8').trim();
const errOut = Buffer.concat(stderr).toString('utf8').trim();
if (code !== 0 && !out) {
log?.warn({ event: payload.event, code, stderr: errOut.slice(0, 200) }, 'hooks: non-zero exit');
resolve({ decision: 'warn', reason: `hook exited ${code}` });
return;
}
// Parse stdout as JSON response
if (out) {
try {
const parsed = JSON.parse(out) as HookResponse;
resolve(parsed);
return;
} catch {
// Not JSON — treat as pass with stdout as context
if (out.length > 0) {
resolve({ decision: 'pass', additional_context: out });
return;
}
}
}
resolve({ decision: 'pass' });
});
// Write payload to stdin
const json = JSON.stringify(payload);
child.stdin.write(json);
child.stdin.end();
});
}

View File

@@ -122,6 +122,8 @@ export async function finalizeStreamedRow(
completionTokens: number | null;
promptTokens: number | null;
startedAt: string | null;
cacheTokens?: number | null;
reasoningTokens?: number | null;
beforeComplete?: () => Promise<void>;
},
): Promise<void> {
@@ -137,6 +139,8 @@ export async function finalizeStreamedRow(
tokens_used = ${opts.completionTokens},
ctx_used = ${opts.promptTokens},
ctx_max = ${nCtx},
cache_tokens = ${opts.cacheTokens ?? null},
reasoning_tokens = ${opts.reasoningTokens ?? null},
finished_at = clock_timestamp()
WHERE id = ${opts.messageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
@@ -149,6 +153,8 @@ export async function finalizeStreamedRow(
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
cache_tokens: opts.cacheTokens ?? null,
reasoning_tokens: opts.reasoningTokens ?? null,
started_at: opts.startedAt,
finished_at: updated?.finished_at ?? null,
model: opts.model,
@@ -188,7 +194,7 @@ export async function finalizeCompletion(
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true });
const { finishReason, promptTokens, completionTokens } = result;
const { finishReason, promptTokens, completionTokens, cacheReadTokens, reasoningTokens } = result;
// v1.11.3: see executeToolPhase for the rationale.
const mctx = await modelContext.getModelContext(session.model);
@@ -203,6 +209,8 @@ export async function finalizeCompletion(
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
cache_tokens = ${cacheReadTokens ?? null},
reasoning_tokens = ${reasoningTokens ?? null},
model = ${session.model},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
@@ -268,6 +276,8 @@ export async function finalizeCompletion(
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
cache_tokens: cacheReadTokens ?? null,
reasoning_tokens: reasoningTokens ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,

View File

@@ -1,4 +1,5 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { createDeepSeek } from '@ai-sdk/deepseek';
import type { LanguageModel } from 'ai';
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
@@ -11,6 +12,12 @@ import type { LanguageModel } from 'ai';
// 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.
//
// vDeepSeek: when the model ID starts with 'deepseek-' and DEEPSEEK_API_KEY
// is set, route through the official @ai-sdk/deepseek provider (not
// openai-compatible) so DeepSeek-specific features work: providerMetadata
// with promptCacheHitTokens/promptCacheMissTokens, reasoning via
// LanguageModelV4Usage.outputTokens.reasoning, and thinking-mode options.
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
@@ -41,7 +48,28 @@ function sidecarProvider(
});
}
export type InferenceRoute = 'swap' | 'sidecar';
const DEEPSEEK_MODEL_PREFIX = 'deepseek-';
export function isDeepSeekModel(modelId: string): boolean {
return modelId.startsWith(DEEPSEEK_MODEL_PREFIX);
}
let deepseekProviderCache: ReturnType<typeof createDeepSeek> | null = null;
function getDeepSeekProvider(
apiKey: string,
baseURL: string,
): ReturnType<typeof createDeepSeek> {
if (!deepseekProviderCache) {
deepseekProviderCache = createDeepSeek({
apiKey,
baseURL,
});
}
return deepseekProviderCache;
}
export type InferenceRoute = 'swap' | 'sidecar' | 'deepseek';
export interface RoutingInfo {
route: InferenceRoute;
@@ -55,12 +83,21 @@ interface AgentLike {
interface ConfigLike {
LLAMA_SWAP_URL: string;
LLAMA_SIDECAR_URL?: string;
DEEPSEEK_API_KEY?: string;
DEEPSEEK_BASE_URL?: string;
}
export function resolveRoute(
agent: AgentLike | null,
config?: ConfigLike,
modelId?: string,
): RoutingInfo {
// vDeepSeek: if the model starts with deepseek- and DEEPSEEK_API_KEY is set,
// route through the DeepSeek provider. Checked first so DeepSeek models
// always bypass llama-swap/sidecar even when those are also configured.
if (modelId?.startsWith(DEEPSEEK_MODEL_PREFIX) && config?.DEEPSEEK_API_KEY) {
return { route: 'deepseek', flags: null };
}
// When llama_extra_args are explicitly set, route through sidecar with them.
const flags = agent?.llama_extra_args;
if (flags && flags.length > 0) {
@@ -80,7 +117,13 @@ export function upstreamModel(
modelId: string,
agent?: AgentLike | null,
): LanguageModel {
const { route, flags } = resolveRoute(agent ?? null, config);
const { route, flags } = resolveRoute(agent ?? null, config, modelId);
if (route === 'deepseek') {
return getDeepSeekProvider(
config.DEEPSEEK_API_KEY!,
config.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
).chat(modelId);
}
if (route === 'sidecar') {
const url = config.LLAMA_SIDECAR_URL;
if (!url) {
@@ -90,3 +133,30 @@ export function upstreamModel(
}
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
}
/** Resolve the API endpoint for non-streaming calls (compaction, task-model).
* Returns the URL + model + optional auth header for direct fetch() usage. */
export function resolveModelEndpoint(
config: ConfigLike,
modelId: string,
): { url: string; model: string; headers: Record<string, string> } {
const baseHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (modelId.startsWith(DEEPSEEK_MODEL_PREFIX) && config.DEEPSEEK_API_KEY) {
const baseURL = (config.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, '');
return {
url: baseURL,
model: modelId,
headers: { ...baseHeaders, Authorization: `Bearer ${config.DEEPSEEK_API_KEY}` },
};
}
return {
url: config.LLAMA_SWAP_URL.replace(/\/+$/, ''),
model: modelId,
headers: baseHeaders,
};
}
/** Invalidate the cached DeepSeek provider (e.g. when env vars change at runtime). */
export function resetDeepSeekProvider(): void {
deepseekProviderCache = null;
}

View File

@@ -13,7 +13,7 @@ import type { OpenAiMessage } from './payload.js';
import { extractToolCallBlocks } from './tool-call-parser.js';
import { classifyStreamError } from './stream-error-classifier.js';
import type { StreamResult } from './types.js';
import { upstreamModel } from './provider.js';
import { isDeepSeekModel, upstreamModel } from './provider.js';
import {
jsonSchema,
streamText,
@@ -51,6 +51,9 @@ export interface StreamOptions {
dry_base?: number | null;
dry_allowed_length?: number | null;
dry_penalty_last_n?: number | null;
// vDeepSeek: thinking/reasoning effort. Maps to DeepSeek's reasoning_effort
// API param for deepseek-v4-flash / deepseek-v4-pro models.
reasoning_effort?: 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
}
// P5: the 10-field sampler-options literal that was copy-pasted at 4 sites
@@ -74,6 +77,7 @@ export function samplerOptsFromAgent(agent: Agent | null): SamplerOpts {
dry_base: agent?.dry_base ?? undefined,
dry_allowed_length: agent?.dry_allowed_length ?? undefined,
dry_penalty_last_n: agent?.dry_penalty_last_n ?? undefined,
reasoning_effort: agent?.reasoning_effort ?? undefined,
};
}
@@ -272,6 +276,19 @@ export async function streamCompletion(
// before this. They now go through the same extraBody path as the new params.
const samplerBody = buildSamplerProviderOptions(opts);
// vDeepSeek: build providerOptions.deepseek for DeepSeek V4 models.
let deepseekProviderOptions:
| { thinking: { type: 'enabled' | 'disabled' }; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' }
| undefined;
if (isDeepSeekModel(model)) {
const dsEffort = opts.reasoning_effort;
const thinkingEnabled = dsEffort && dsEffort !== 'off';
deepseekProviderOptions = {
thinking: { type: thinkingEnabled ? 'enabled' : 'disabled' },
...(thinkingEnabled ? { reasoningEffort: dsEffort } : {}),
};
}
// F6: per-chunk stall deadline. If the model stops emitting chunks for
// STALL_TIMEOUT_MS the stallAc fires through AbortSignal.any; the post-loop
// abort check below then throws AbortError → handleAbortOrError writes
@@ -297,7 +314,14 @@ export async function streamCompletion(
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}),
...(samplerBody || deepseekProviderOptions
? {
providerOptions: {
...(samplerBody ? { openaiCompatible: samplerBody } : {}),
...(deepseekProviderOptions ? { deepseek: deepseekProviderOptions } : {}),
},
}
: {}),
abortSignal: effectiveSignal,
});
@@ -401,12 +425,26 @@ export async function streamCompletion(
// Usage lands as a promise on the result; awaiting after fullStream is
// drained is safe. AI SDK v6 names: `inputTokens` / `outputTokens`.
// Some providers (llama-swap via openai-compatible) return plain numbers;
// others (deepseek via @ai-sdk/deepseek) return {total, cacheRead, noCache, ...}.
let promptTokens: number | null = null;
let completionTokens: number | null = null;
let cacheReadTokens: number | null = null;
let reasoningTokens: number | null = null;
try {
const usage = await result.usage;
if (typeof usage.inputTokens === 'number') promptTokens = usage.inputTokens;
if (typeof usage.outputTokens === 'number') completionTokens = usage.outputTokens;
if (typeof usage.inputTokens === 'number') {
promptTokens = usage.inputTokens;
} else if (usage.inputTokens && typeof usage.inputTokens === 'object') {
promptTokens = (usage.inputTokens as Record<string, number | undefined>).total ?? null;
cacheReadTokens = (usage.inputTokens as Record<string, number | undefined>).cacheRead ?? null;
}
if (typeof usage.outputTokens === 'number') {
completionTokens = usage.outputTokens;
} else if (usage.outputTokens && typeof usage.outputTokens === 'object') {
completionTokens = (usage.outputTokens as Record<string, number | undefined>).total ?? null;
reasoningTokens = (usage.outputTokens as Record<string, number | undefined>).reasoning ?? null;
}
} catch {
// Some providers omit usage on partial streams; leave both null.
}
@@ -422,6 +460,13 @@ export async function streamCompletion(
);
}
if (cacheReadTokens !== null || reasoningTokens !== null) {
ctx.log.debug(
{ promptTokens, completionTokens, cacheReadTokens, reasoningTokens, model },
'streamCompletion: deepseek usage breakdown',
);
}
return {
finishReason,
content,
@@ -429,6 +474,10 @@ export async function streamCompletion(
promptTokens,
completionTokens,
reasoning: reasoningAccumulated,
// vDeepSeek: optional usage breakdown populated when the provider returns
// structured usage (cache hit tokens, reasoning tokens).
cacheReadTokens: cacheReadTokens ?? undefined,
reasoningTokens: reasoningTokens ?? undefined,
};
} finally {
// Clear the stall timer whether the stream completes normally, throws, or

View File

@@ -0,0 +1,179 @@
/**
* vWhale: schema-based tool input repair. When the model emits tool call args
* that don't match the expected types (common with weaker models), apply
* heuristic repairs before falling through to the Zod parse.
*
* Inspired by Whale's RepairToolInputForSpec:
* - Coerce string "true"/"false" → boolean
* - Unwrap markdown autolinks in string fields: <file:///path> → /path
* - Wrap bare values in arrays when schema expects array
* - Convert "42.0" decimal string → "42" for integer fields
* - Recurse into objects to repair nested properties
*/
export interface ToolInputRepair {
field: string;
kind: string;
detail: string;
}
const MARKDOWN_AUTOLINK_RE = /^<(?:file|path):\/\/(.+?)>$/;
/**
* Attempt to repair tool call args against the tool's JSON Schema.
* Returns the (possibly modified) args plus a list of repairs applied.
*/
export function repairToolInput(
schema: Record<string, unknown> | undefined,
args: Record<string, unknown>,
): { repaired: Record<string, unknown>; repairs: ToolInputRepair[] } {
const repairs: ToolInputRepair[] = [];
if (!schema || typeof schema !== 'object') {
return { repaired: args, repairs };
}
const properties = (schema as Record<string, unknown>).properties as
Record<string, unknown> | undefined;
if (!properties) {
return { repaired: args, repairs };
}
const required = new Set<string>(
Array.isArray((schema as Record<string, unknown>).required)
? (schema as Record<string, unknown>).required as string[]
: [],
);
const repaired: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
const propSchema = properties[key] as Record<string, unknown> | undefined;
if (propSchema && value !== null && value !== undefined) {
repaired[key] = repairValue(key, propSchema, value, repairs, required.has(key));
} else {
repaired[key] = value;
}
}
// Drop keys not in the schema (only for required fields that are missing)
// to avoid polluting the model with hallucinated params.
for (const key of Object.keys(repaired)) {
if (!(key in properties)) {
repairs.push({ field: key, kind: 'removed_unknown', detail: `Removed unknown parameter '${key}'` });
delete repaired[key];
}
}
return { repaired, repairs };
}
function repairValue(
field: string,
schema: Record<string, unknown>,
value: unknown,
repairs: ToolInputRepair[],
required: boolean,
): unknown {
const schemaType = schema.type;
const isArray = schemaType === 'array' || Array.isArray(schemaType)
? schemaType === 'array' || (Array.isArray(schemaType) && schemaType.includes('array'))
: false;
const isObject = schemaType === 'object';
const isBoolean = schemaType === 'boolean';
const isInteger = schemaType === 'integer' || schemaType === 'number';
const isString = schemaType === 'string';
// --- Array repair: wrap bare value or empty object ---
if (isArray) {
if (!Array.isArray(value)) {
if (typeof value === 'string') {
// Try parsing as JSON array first
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
repairs.push({ field, kind: 'parsed_json_array', detail: `Parsed string as JSON array for '${field}'` });
return parsed;
}
} catch { /* not JSON */ }
}
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
if (required) {
repairs.push({ field, kind: 'empty_object_to_array', detail: `Converted empty object to empty array for '${field}'` });
return [];
}
repairs.push({ field, kind: 'empty_object_to_undefined', detail: `Removed empty object for optional array '${field}'` });
return undefined;
}
repairs.push({ field, kind: 'wrapped_in_array', detail: `Wrapped bare value in array for '${field}'` });
return [value];
}
// Recurse into array items
const itemsSchema = schema.items as Record<string, unknown> | undefined;
if (itemsSchema) {
return value.map((item, i) => repairValue(`${field}[${i}]`, itemsSchema, item, repairs, required));
}
return value;
}
// --- Object repair: recurse into properties ---
if (isObject && typeof value === 'object' && value !== null && !Array.isArray(value)) {
const props = (schema.properties as Record<string, unknown>) ?? {};
const repaired: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const propSchema = props[k] as Record<string, unknown> | undefined;
if (propSchema) {
repaired[k] = repairValue(`${field}.${k}`, propSchema, v, repairs, required);
} else {
repaired[k] = v;
}
}
return repaired;
}
// --- String repair: unwrap markdown autolinks ---
if (isString && typeof value === 'string') {
const match = value.match(MARKDOWN_AUTOLINK_RE);
if (match) {
repairs.push({ field, kind: 'unwrapped_markdown_link', detail: `Unwrapped markdown autolink for '${field}': ${value}` });
return match[1];
}
return value;
}
// --- Boolean coercion ---
if (isBoolean && typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true') {
repairs.push({ field, kind: 'coerced_to_boolean', detail: `Coerced string '${value}' → true for '${field}'` });
return true;
}
if (lower === 'false') {
repairs.push({ field, kind: 'coerced_to_boolean', detail: `Coerced string '${value}' → false for '${field}'` });
return false;
}
return value;
}
// --- Integer coercion: "42.0" → 42 ---
if (isInteger && typeof value === 'string') {
const num = Number(value);
if (!Number.isNaN(num)) {
repairs.push({ field, kind: 'coerced_to_number', detail: `Coerced string '${value}' → ${num} for '${field}'` });
return num;
}
return value;
}
// --- Integer coercion: boolean → 0/1 ---
if (isInteger && typeof value === 'boolean') {
repairs.push({ field, kind: 'coerced_boolean_to_integer', detail: `Coerced boolean ${value}${value ? 1 : 0} for '${field}'` });
return value ? 1 : 0;
}
// --- Empty string to null for optional fields ---
if (value === '' && !required) {
repairs.push({ field, kind: 'empty_string_to_undefined', detail: `Converted empty string for optional '${field}'` });
return undefined;
}
return value;
}

View File

@@ -6,6 +6,7 @@ import type { ToolExecCtx } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
import { getServerPermission } from '../mcp-client.js';
// v1.13.16: richer unknown-tool error so the model can self-correct when it
// drifts to a Claude Code tool name (e.g. read_file → suggest view_file).
// Applies to all unknown tool names, not just <invoke>-derived ones — at the
@@ -17,7 +18,9 @@ import { formatUnknownToolError } from './tool-suggestions.js';
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js';
import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type {
InferenceContext,
StreamResult,
@@ -34,6 +37,8 @@ async function executeToolCall(
toolCall: ToolCall,
extraRoots: readonly string[],
toolCtx?: ToolExecCtx,
hooks?: import('../hooks.js').HookRunner,
sessionId?: string,
): Promise<{ output: unknown; truncated: boolean; error?: string; outcome: FailureKind | 'success' }> {
// v#12 MistakeTracker: every return path carries an `outcome` so the turn
// loop can detect a run of heterogeneous failures. The failure taxonomy
@@ -48,7 +53,61 @@ async function executeToolCall(
outcome: 'tool_not_found',
};
}
const parsed = tool.inputSchema.safeParse(toolCall.args);
// MCP permission gate — block deny/ask before any Zod parsing or execution
const mcpPerm = getServerPermission(toolCall.name);
if (mcpPerm === 'deny') {
return { output: null, truncated: false, error: `blocked: MCP server denied tool '${toolCall.name}'`, outcome: 'permission_denied' };
}
if (mcpPerm === 'ask') {
return { output: null, truncated: false, error: `requires approval: tool '${toolCall.name}' needs user approval`, outcome: 'permission_denied' };
}
// vWhale: schema-based tool input repair. If the Zod parse fails, attempt
// heuristic repairs (type coercion, markdown-link unwrapping, array wrapping)
// and retry. Logs repairs for debugging.
let args = toolCall.args;
let parsed = tool.inputSchema.safeParse(args);
if (!parsed.success) {
const schema = tool.jsonSchema?.function?.parameters;
if (schema) {
const { repaired: repairedArgs, repairs } = repairToolInput(
schema as Record<string, unknown>,
args as Record<string, unknown>,
);
if (repairs.length > 0) {
const retry = tool.inputSchema.safeParse(repairedArgs);
if (retry.success) {
args = repairedArgs;
parsed = retry;
}
}
}
}
// vWhale: PreToolUse hook — can block execution.
if (hooks && sessionId) {
const hookResult = await hooks.run('PreToolUse', {
event: 'PreToolUse',
session_id: sessionId,
tool_name: toolCall.name,
tool_args: args as Record<string, unknown>,
});
if (hookResult.decision === 'block') {
return {
output: null,
truncated: false,
error: `blocked by hook: ${hookResult.reason ?? 'PreToolUse denied'}`,
outcome: 'permission_denied',
};
}
// Apply updated_input if the hook rewrote the args
if (hookResult.updated_input && typeof hookResult.updated_input === 'object') {
const reParsed = tool.inputSchema.safeParse(hookResult.updated_input);
if (reParsed.success) {
args = hookResult.updated_input as Record<string, unknown>;
parsed = reParsed;
}
}
}
if (!parsed.success) {
// v1.12 Track B.2: enrich the zod-reject path so the model sees a
// one-line, tool-named hint ("tool 'search_symbols' rejected — query:
@@ -117,6 +176,7 @@ export async function executeToolPhase(
session: Session,
projectRoot: string,
agent?: Agent | null,
turnNumber?: number,
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true });
@@ -183,6 +243,8 @@ export async function executeToolPhase(
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
cache_tokens: result.cacheReadTokens ?? null,
reasoning_tokens: result.reasoningTokens ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
@@ -318,10 +380,64 @@ export async function executeToolPhase(
});
return;
}
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
sql: ctx.sql,
sessionId,
// tool_trace instrumentation - start
const traceId = crypto.randomUUID();
const traceStartTime = Date.now();
const startedAtIso = new Date().toISOString();
insertToolTrace(ctx.sql, {
session_id: sessionId,
chat_id: chatId,
message_id: assistantMessageId,
turn_number: turnNumber ?? 0,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_start',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
tool_input: tc.args as Record<string, unknown>,
started_at: startedAtIso,
});
const tres = await executeToolCall(
projectRoot, tc, session.allowed_read_paths,
{ sql: ctx.sql, sessionId },
ctx.hooks, sessionId,
);
// tool_trace instrumentation - finish
const finishedAtIso = new Date().toISOString();
const latencyMs = Date.now() - traceStartTime;
updateToolTrace(ctx.sql, traceId, {
finished_at: finishedAtIso,
...(tres.outcome === 'success' && tres.output != null ? { tool_output: JSON.stringify(tres.output) } : {}),
latency_ms: latencyMs,
outcome: tres.outcome,
...(tres.error ? { error: tres.error } : {}),
}).catch(() => {});
ctx.publish(sessionId, {
type: 'tool_trace_finish',
trace_id: traceId,
message_id: assistantMessageId,
chat_id: chatId,
tool_name: tc.name,
finished_at: finishedAtIso,
outcome: tres.outcome,
latency_ms: latencyMs,
...(tres.error ? { error: tres.error } : {}),
});
// vWhale: PostToolUse hook (best-effort, non-blocking).
if (ctx.hooks) {
ctx.hooks.run('PostToolUse', {
event: 'PostToolUse',
session_id: sessionId,
tool_name: tc.name,
tool_args: tc.args as Record<string, unknown>,
tool_result: tres.output,
tool_error: tres.error,
}).catch(() => {});
}
// v#12 MistakeTracker: record the real execution outcome (success or a
// FailureKind). This is the primary signal for heterogeneous-failure
// detection.

View File

@@ -37,6 +37,12 @@ import type {
StreamResult,
TurnArgs,
} from './types.js';
import { saveAgentSnapshot } from '../session-snapshots.js';
// vWhale: auto-fix loop — after write tools, build the project and inject
// errors. Uses execFile (no shell) against the project root.
import { execFile } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import {
runCapHitSummary,
runDoomLoopSummary,
@@ -44,6 +50,71 @@ import {
insertMistakeRecoverySentinel,
} from './sentinel-summaries.js';
// vWhale: auto-fix — detect build command from package.json, run it, return
// error text for injection into next iteration. Best-effort, never throws.
const BUILD_TIMEOUT_MS = 60_000;
const BUILD_OUTPUT_CAP = 8_000;
async function detectAndRunBuild(
ctx: InferenceContext,
projectRoot: string,
sessionId: string,
chatId: string,
model: string,
existingNote: string | undefined,
): Promise<string | undefined> {
// Only run for DeepSeek models (local Qwen models don't benefit from build loop).
if (!model.startsWith('deepseek-')) return undefined;
// Detect build command from package.json in project root.
const pkgPath = join(projectRoot, 'package.json');
if (!existsSync(pkgPath)) return undefined;
let buildCmd: string | null = null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
if (pkg.scripts?.build) buildCmd = 'build';
else if (pkg.scripts?.compile) buildCmd = 'compile';
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
} catch {
return undefined;
}
if (!buildCmd) return undefined;
// Detect package manager.
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
// Run the build.
try {
const out = await new Promise<string>((resolve, reject) => {
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
(err, stdout, stderr) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve(''); // package manager not found — skip
return;
}
const merged = (stdout + '\n' + stderr).trim();
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
},
);
});
if (!out) return undefined; // build succeeded or no output
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
// Truncate if existing note exists
const combined = existingNote
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
return combined;
} catch {
return undefined;
}
}
// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported
// here so the public surface (index.ts → './turn.js') is unchanged.
export { MAX_STEPS } from './turn-config.js';
@@ -144,6 +215,7 @@ export async function runAssistantTurn(
log: ctx.log,
broker: ctx.broker,
chatId,
hooks: ctx.hooks,
});
} catch (err) {
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
@@ -214,6 +286,16 @@ export async function runAssistantTurn(
// ---- non-tool finish → finalize and exit ----
if (result.toolCalls.length === 0) {
// vWhale: Stop hook (best-effort, non-blocking).
if (ctx.hooks) {
ctx.hooks.run('Stop', {
event: 'Stop',
session_id: sessionId,
chat_id: chatId,
last_assistant_text: result.content.slice(0, 500),
turn: stepNumber,
}).catch(() => {});
}
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
break;
}
@@ -229,7 +311,7 @@ export async function runAssistantTurn(
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.
@@ -249,6 +331,17 @@ export async function runAssistantTurn(
recordStep(mistakeTracker, o);
}
// vWhale: auto-fix — after write tools, attempt build and inject errors.
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
if (hasWriteTools) {
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
.then((buildError) => {
if (buildError) pendingRecoveryNote = buildError;
})
.catch(() => {});
}
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
// returned a non-'continue' action ('paused' for user input, or
// 'synthesis_done') — neither a nudge nor an escalate would change the
@@ -309,6 +402,35 @@ export async function runAssistantTurn(
assistantMessageId = toolPhaseResult.nextAssistantId!;
}
// vWhale: Stop hook at post-loop exit (best-effort, non-blocking).
if (ctx.hooks) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
const lastAssistant = loaded?.history?.slice().reverse().find(
(m: import('../../types/api.js').Message) => m.role === 'assistant',
);
const content = lastAssistant?.content ?? '';
ctx.hooks.run('Stop', {
event: 'Stop',
session_id: sessionId,
chat_id: chatId,
last_assistant_text: content.slice(0, 500),
turn: stepNumber,
}).catch(() => {});
}
// ---- persist agent snapshot (best-effort, never blocks inference) ----
const snapLoaded = await loadContext(ctx.sql, sessionId, chatId).catch(() => null);
if (snapLoaded) {
await saveAgentSnapshot(ctx.sql, chatId, {
session_id: sessionId,
model: snapLoaded.session.model,
agent: agent?.name ?? null,
mode: null,
turn_number: stepNumber,
messages: snapLoaded.history.map((m) => ({ role: m.role, content: m.content })),
}).catch(() => {});
}
// ---- post-loop: step-cap sentinel ----
// When the loop exits because stepNumber reached effectiveCap, the last
// iteration's tool phase returned 'continue' with a nextAssistantId that

View File

@@ -19,6 +19,7 @@ import type {
UserStreamFrame,
} from '../../types/api.js';
import type { Broker } from '../broker.js';
import type { HookRunner } from '../hooks.js';
import type { MistakeState } from './mistake-tracker.js';
export interface StreamPhaseState {
@@ -45,6 +46,9 @@ export interface InferenceFrame {
| 'error'
| 'flow_run_started'
| 'flow_run_step_updated'
// tool trace frames
| 'tool_trace_start'
| 'tool_trace_finish'
// arena frames
| 'battle_started'
| 'contestant_updated'
@@ -77,8 +81,19 @@ export interface InferenceFrame {
started_at?: string | null;
finished_at?: string | null;
model?: string;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
session_id?: string;
name?: string;
// tool trace frames
trace_id?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_output?: string | null;
latency_ms?: number;
outcome?: string;
// agent snapshot restore
agent?: string | null;
// orchestrator frames ([D-6])
run_id?: string;
flow_name?: string;
@@ -117,6 +132,9 @@ export interface InferenceContext {
// inference goes through `publish`); keeping a separate field avoids
// tempting other code paths into bypassing the session-id binding.
broker: Broker;
// vWhale: lifecycle hooks runner. Undefined when no hooks configured.
// Hook calls are best-effort — a failing hook never blocks inference.
hooks?: HookRunner;
}
export interface StreamResult {
@@ -128,6 +146,12 @@ export interface StreamResult {
// v1.13.1-C: reasoning text accumulated across reasoning-delta parts.
// Empty string when the model doesn't emit reasoning (most cases).
reasoning: string;
// vDeepSeek: optional cache-hit token count from DeepSeek's API.
// Only populated when using @ai-sdk/deepseek provider (not llama-swap).
cacheReadTokens?: number;
// vDeepSeek: optional reasoning token count from DeepSeek's API.
// Only populated when using @ai-sdk/deepseek provider (not llama-swap).
reasoningTokens?: number;
}
export interface TurnArgs {

View File

@@ -31,11 +31,14 @@ interface McpToolDef {
annotations?: McpToolAnnotations;
}
export type McpPermission = 'allow' | 'ask' | 'deny';
interface ServerState {
client: Client;
transport: StreamableHTTPClientTransport | StdioClientTransport;
tools: ToolDef<Record<string, unknown>>[];
type: 'streamableHttp' | 'stdio';
permission: McpPermission;
}
// ---- Module-level state ----
@@ -137,6 +140,14 @@ export async function callTool(
}
}
/** Return the permission level for a given MCP server. Defaults to 'allow' if unknown. */
export function getServerPermission(prefixedToolName: string): McpPermission {
const serverName = toolToServer.get(prefixedToolName);
if (!serverName) return 'allow';
const state = servers.get(serverName);
return state?.permission ?? 'allow';
}
/** Return all wrapped ToolDefs from all connected servers, flattened. */
export function getTools(): ToolDef<Record<string, unknown>>[] {
const all: ToolDef<Record<string, unknown>>[] = [];
@@ -214,7 +225,8 @@ async function connectServer(entry: McpServerEntry): Promise<void> {
toolToServer.set(wrapped.name, name);
}
servers.set(name, { client, transport, tools, type: config.type });
const permission = (config as { permission?: McpPermission }).permission ?? 'allow';
servers.set(name, { client, transport, tools, type: config.type, permission });
log!.info(
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },

View File

@@ -17,12 +17,15 @@ import type { FastifyBaseLogger } from 'fastify';
// ---- Zod schema ----
const McpPermissionSchema = z.enum(['allow', 'ask', 'deny']).default('allow');
const McpServerConfigSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('streamableHttp'),
url: z.string().url(),
headers: z.record(z.string()).optional(),
enabled: z.boolean().default(true),
permission: McpPermissionSchema,
}),
z.object({
type: z.literal('stdio'),
@@ -30,6 +33,7 @@ const McpServerConfigSchema = z.discriminatedUnion('type', [
args: z.array(z.string()).default([]),
env: z.record(z.string()).optional(),
enabled: z.boolean().default(true),
permission: McpPermissionSchema,
}),
]);

View File

@@ -3,4 +3,9 @@ export { formatMemoryBlock } from './prompt.js';
export { scanMemoryScopes } from './scan.js';
export { parseMemoryEntries } from './entries.js';
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
export { ContextTier } from './context-tier.js';
export { DeepDream } from './deep-dream.js';
export { CoreTier } from './core-tier.js';
export type { MemoryEntry } from './entries.js';
export type { ContextTierConfig, ConversationTurn } from './context-tier.js';
export type { CoreTierEntry, CoreTierSearchResult, CoreTierSearchOptions } from './core-tier.js';

View File

@@ -7,10 +7,12 @@
export const MESSAGE_COLUMNS =
'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' +
'tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, ' +
'tokens_used, ctx_used, ctx_max, cache_tokens, reasoning_tokens, ' +
'started_at, finished_at, created_at, metadata, ' +
'summary, tail_start_id, compacted_at, model';
export const INFERENCE_MESSAGE_COLUMNS =
'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' +
'tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, ' +
'tokens_used, ctx_used, ctx_max, cache_tokens, reasoning_tokens, ' +
'started_at, finished_at, created_at, metadata, ' +
'reasoning_parts, model';

View File

@@ -37,7 +37,18 @@ export function configureModelContext(opts: { llamaSwapUrl: string }): void {
llamaSwapUrl = opts.llamaSwapUrl;
}
// vDeepSeek: DeepSeek models don't have a /upstream/<model>/props endpoint.
// Return a reasonable default context so compaction estimates work.
const DEEPSEEK_DEFAULT_N_CTX = 131_072;
const DEEPSEEK_MODEL_PREFIX = 'deepseek-';
export async function getModelContext(model: string): Promise<ModelContext | null> {
// vDeepSeek: DeepSeek models have no /upstream/<model>/props. Use a static
// default so compaction doesn't fall to the buffer-only path with tiny limits.
if (model.startsWith(DEEPSEEK_MODEL_PREFIX)) {
return { n_ctx: DEEPSEEK_DEFAULT_N_CTX };
}
// 1. Positive cache hit — no TTL check, model n_ctx is invariant.
const pos = positiveCache.get(model);
if (pos) return pos;

View File

@@ -0,0 +1,51 @@
import type { Sql } from '../db.js';
export interface AgentSnapshot {
id: string;
session_id: string;
chat_id: string;
model: string;
agent: string | null;
mode: string | null;
turn_number: number;
messages: unknown[];
tool_states: unknown[];
created_at: string;
updated_at: string;
}
/** Save or update the agent snapshot for a chat (UPSERT). */
export async function saveAgentSnapshot(sql: Sql, chatId: string, data: {
session_id: string;
model: string;
agent?: string | null;
mode?: string | null;
turn_number: number;
messages: unknown[];
tool_states?: unknown[];
}): Promise<void> {
await sql`
INSERT INTO agent_snapshots (session_id, chat_id, model, agent, mode, turn_number, messages, tool_states, updated_at)
VALUES (${data.session_id}, ${chatId}, ${data.model}, ${data.agent ?? null}, ${data.mode ?? null}, ${data.turn_number}, ${sql.json(data.messages as never)}, ${sql.json((data.tool_states ?? []) as never)}, clock_timestamp())
ON CONFLICT (chat_id)
DO UPDATE SET
model = EXCLUDED.model,
agent = EXCLUDED.agent,
mode = EXCLUDED.mode,
turn_number = EXCLUDED.turn_number,
messages = EXCLUDED.messages,
tool_states = EXCLUDED.tool_states,
updated_at = clock_timestamp()
`;
}
/** Load the agent snapshot for a chat. Returns null if no snapshot exists. */
export async function loadAgentSnapshot(sql: Sql, chatId: string): Promise<AgentSnapshot | null> {
const rows = await sql<AgentSnapshot[]>`SELECT * FROM agent_snapshots WHERE chat_id = ${chatId}`;
return rows[0] ?? null;
}
/** Delete the agent snapshot for a chat (call when session ends). */
export async function deleteAgentSnapshot(sql: Sql, chatId: string): Promise<void> {
await sql`DELETE FROM agent_snapshots WHERE chat_id = ${chatId}`;
}

View File

@@ -101,7 +101,7 @@ export interface PrefixFingerprint {
has_agent_system_prompt: boolean;
has_session_override: boolean;
has_project_override: boolean;
route: 'swap' | 'sidecar';
route: 'swap' | 'sidecar' | 'deepseek';
}
export interface PrefixDrift {
@@ -129,7 +129,7 @@ interface ObservedInputs {
has_agent_system_prompt: boolean;
has_session_override: boolean;
has_project_override: boolean;
route: 'swap' | 'sidecar';
route: 'swap' | 'sidecar' | 'deepseek';
}
interface ObserverEntry {

View File

@@ -0,0 +1,92 @@
import type { Sql } from '../db.js';
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceInsert {
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: unknown;
outcome?: string;
}
export interface ToolTraceUpdate {
finished_at?: string;
latency_ms?: number;
tool_output?: string;
tokens_used?: number;
cache_tokens?: number;
reasoning_tokens?: number;
error?: string;
outcome?: string;
}
export async function insertToolTrace(
sql: Sql,
insert: ToolTraceInsert,
): Promise<ToolTrace> {
const [row] = await sql<ToolTrace[]>`
INSERT INTO tool_traces (
session_id, chat_id, message_id, turn_number,
tool_name, tool_input, outcome
) VALUES (
${insert.session_id}, ${insert.chat_id}, ${insert.message_id},
${insert.turn_number}, ${insert.tool_name},
${sql.json(insert.tool_input as never)},
${insert.outcome ?? null}
)
RETURNING *
`;
if (!row) throw new Error('insertToolTrace returned no row');
return row;
}
export async function updateToolTrace(
sql: Sql,
id: string,
updates: ToolTraceUpdate,
): Promise<ToolTrace | null> {
const cols: string[] = [];
const vals: any[] = [];
if (updates.finished_at !== undefined) { cols.push('finished_at'); vals.push(updates.finished_at); }
if (updates.latency_ms !== undefined) { cols.push('latency_ms'); vals.push(updates.latency_ms); }
if (updates.tool_output !== undefined) { cols.push('tool_output'); vals.push(updates.tool_output); }
if (updates.tokens_used !== undefined) { cols.push('tokens_used'); vals.push(updates.tokens_used); }
if (updates.cache_tokens !== undefined) { cols.push('cache_tokens'); vals.push(updates.cache_tokens); }
if (updates.reasoning_tokens !== undefined) { cols.push('reasoning_tokens'); vals.push(updates.reasoning_tokens); }
if (updates.error !== undefined) { cols.push('error'); vals.push(updates.error); }
if (updates.outcome !== undefined) { cols.push('outcome'); vals.push(updates.outcome); }
if (cols.length === 0) {
const [row] = await sql<ToolTrace[]>`SELECT * FROM tool_traces WHERE id = ${id}`;
return row ?? null;
}
const setClause = cols.map((c, i) => `${c} = $${i + 1}`).join(', ');
const [row] = await sql.unsafe<ToolTrace[]>(
`UPDATE tool_traces SET ${setClause} WHERE id = $${cols.length + 1} RETURNING *`,
[...vals, id],
);
return row ?? null;
}

View File

@@ -2,6 +2,12 @@ import { z } from 'zod';
import type { ToolDef } from '../types.js';
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
// DEPRECATED (Phase 4, Domain 2, v2.8.14): This factory builds ToolDefs that
// route through the Go codecontext sidecar via callCodecontext(). Superseded
// by direct boocontext MCP tool wrappers. Keep functional for backward
// compatibility — old codecontext tools still use HTTP. New tools should use
// the boocontext MCP server instead of adding entries here.
//
// Shared factory for the 12 codecontext shim ToolDefs.
// Each shim provides name/schema/description/jsonParameters/mapArgs; the
// factory builds the ToolDef and returns both the ToolDef and the standalone

View File

@@ -3,6 +3,7 @@ import { makeCodecontextTool } from './factory.js';
export const GetCodebaseOverviewInput = z.object({
include_stats: z.boolean().optional(),
compress: z.boolean().optional().describe('Apply DCP compression for large projects (>50 files)'),
});
export type GetCodebaseOverviewInputT = z.infer<typeof GetCodebaseOverviewInput>;
@@ -24,10 +25,18 @@ const { toolDef: getCodebaseOverview, execute: executeGetCodebaseOverview } =
type: 'boolean',
description: 'Include file count, symbol count, language stats. Defaults to true.',
},
compress: {
type: 'boolean',
description: 'Apply DCP compression for large projects (>50 files)',
},
},
additionalProperties: false,
},
mapArgs: (input) => ({ include_stats: input.include_stats ?? true }),
mapArgs: (input) => {
const args: Record<string, unknown> = { include_stats: input.include_stats ?? true };
if (input.compress) args['compress'] = true;
return args;
},
});
export { getCodebaseOverview, executeGetCodebaseOverview };

View File

@@ -18,3 +18,4 @@ export { getCodeHealth } from './get_code_health.js';
export { getCodeImpact } from './get_code_impact.js';
export { getTypeInfo } from './get_type_info.js';
export { getCodeMap } from './get_code_map.js';
export { getWikiArticle } from './get_wiki_article.js';

View File

@@ -0,0 +1,132 @@
/**
* vWhale: run_command tool. Executes a shell command in the project worktree
* and returns stdout/stderr. Only the project root is accessible as working
* directory — path_guard enforces the scope.
*
* Security model:
* - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion.
* - args passed as array, never a string.
* - 30s timeout default, configure per-call.
* - 32KB output cap with truncation (same pattern as web_fetch.ts).
* - Working directory restricted to project root via path_guard.
* - No background processes allowed (waits for completion).
*/
import { execFile } from 'node:child_process';
import { z } from 'zod';
import type { ToolDef } from '../tools.js';
const RunCommandInput = z.object({
command: z.string().min(1).max(256),
args: z.array(z.string()).default([]),
description: z.string().max(256).optional(),
timeout_ms: z.number().int().positive().max(120_000).optional(),
});
export type RunCommandInputT = z.infer<typeof RunCommandInput>;
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_CHARS = 32_000;
export type RunCommandOutput =
| {
command: string;
args: string[];
exit_code: number;
stdout: string;
stderr: string;
truncated: boolean;
duration_ms: number;
}
| {
error: string;
reason: string;
};
export async function executeRunCommand(
input: RunCommandInputT,
projectRoot: string,
): Promise<RunCommandOutput> {
const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now();
return new Promise((resolve) => {
const child = execFile(
input.command,
input.args,
{
cwd: projectRoot,
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_CHARS * 2,
env: { ...process.env },
},
(err, stdout, stderr) => {
const durationMs = Date.now() - startTime;
// Truncate output if needed
const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS;
const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout;
const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr;
const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0;
resolve({
command: input.command,
args: input.args,
exit_code: typeof exitCode === 'number' ? exitCode : 1,
stdout: cappedStdout,
stderr: cappedStderr,
truncated,
duration_ms: durationMs,
});
},
);
});
}
export const runCommand: ToolDef<RunCommandInputT> = {
name: 'run_command',
description:
'Run a shell command in the project workspace and return stdout + stderr. ' +
'The command runs in the project root directory. ' +
'Use for: building, testing, linting, git operations, running scripts. ' +
'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' +
'Security: args are passed as array (no shell injection). No background processes.',
inputSchema: RunCommandInput as unknown as z.ZodType<RunCommandInputT>,
jsonSchema: {
type: 'function',
function: {
name: 'run_command',
description:
'Execute a command in the project workspace. ' +
'Use for builds, tests, linting, git commands, and scripts. ' +
'The process runs with a 30s timeout and 32KB output cap.',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).',
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.',
},
description: {
type: 'string',
description: 'Optional human-readable description of what this command does.',
},
timeout_ms: {
type: 'integer',
description: 'Timeout in milliseconds. Default 30000, max 120000.',
},
},
required: ['command'],
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return await executeRunCommand(input, projectRoot);
},
};

View File

@@ -23,6 +23,7 @@ import {
getCodeImpact,
getTypeInfo,
getCodeMap,
getWikiArticle,
} from './codecontext/index.js';
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
@@ -31,6 +32,14 @@ import { requestReadAccess } from '../request_read_access.js';
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
import { readTabByNumber } from '../read_tab_by_number.js';
// v2.x: memory management tools. file-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
import { extractMemoryTool } from './extract_memory.js';
import { manageMemoryTool } from './manage_memory.js';
import { searchMemoryTool } from './search_memory.js';
// vWhale: command execution tool. Spawns processes in the project worktree
// with timeout and output cap. No shell — args are passed as array.
import { runCommand } from './execute-command.js';
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the top
@@ -85,6 +94,17 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
getCodeImpact as ToolDef<unknown>,
getTypeInfo as ToolDef<unknown>,
getCodeMap as ToolDef<unknown>,
// v2.8.14-domain2-phase3: wiki mode + token-efficient scanning.
getWikiArticle as ToolDef<unknown>,
// v2.x: memory management tools. File-based store with optional CoreTier
// (SQLite FTS5 + vector) hybrid search backend.
extractMemoryTool as ToolDef<unknown>,
manageMemoryTool as ToolDef<unknown>,
searchMemoryTool as ToolDef<unknown>,
// vWhale: command execution. Spawns processes in the project worktree.
// Read-write; use with guard: restricted to project root via path_guard,
// no shell injection (execFile, not exec).
runCommand as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name));
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -127,6 +127,9 @@ export interface Agent {
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
llama_extra_args: string[] | null;
// vDeepSeek: thinking/reasoning effort for DeepSeek V4 models.
// Maps to DeepSeek's reasoning_effort API param.
reasoning_effort: 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the
@@ -206,6 +209,8 @@ export interface Message {
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
started_at: string | null;
finished_at: string | null;
created_at: string;

View File

@@ -34,6 +34,10 @@ import type {
SessionAnalyticsRow,
ContextWindowStats,
TokenBreakdownAgg,
ToolTraceResponse,
MemoryEntry,
DailyMemoryEntry,
DreamEntry,
} from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
@@ -340,6 +344,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
}),
getTraces: (chatId: string, limit = 50, offset = 0) =>
request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
),
},
messages: {
@@ -608,6 +616,22 @@ export const api = {
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
},
// memory-browser-ui: topic-based memory, daily log, dream diaries.
memory: {
list: (projectId: string) =>
request<{ entries: MemoryEntry[] }>(
`/api/memory?project_id=${encodeURIComponent(projectId)}`,
),
daily: (projectId: string) =>
request<{ entries: DailyMemoryEntry[] }>(
`/api/memory/daily?project_id=${encodeURIComponent(projectId)}`,
),
dreams: (projectId: string) =>
request<{ entries: DreamEntry[] }>(
`/api/memory/dreams?project_id=${encodeURIComponent(projectId)}`,
),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>

View File

@@ -152,6 +152,8 @@ export interface Message {
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
// model-attribution: which model produced this assistant message (null for
// user/system rows + pre-attribution messages). Rendered as a chip.
model: string | null;
@@ -530,6 +532,8 @@ export type WsFrame =
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
started_at?: string | null;
finished_at?: string | null;
// model-attribution: the model that produced this assistant message.
@@ -555,8 +559,16 @@ export type WsFrame =
ctx_used: number | null;
ctx_max: number | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| {
type: 'agent_snapshot';
chat_id: string;
agent?: string | null;
model: string;
mode?: string | null;
turn_number: number;
}
// v1.11: published by services/compaction.ts after the new anchored
// summary row lands. Carries the new summary row id for diagnostics; the
// session-stream handler ignores the id and re-fetches the full message
@@ -600,6 +612,31 @@ export type WsFrame =
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
}
// tool trace frames: per-tool-call lifecycle tracking
| {
type: 'tool_trace_start';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_input: Record<string, unknown>;
started_at: string;
}
| {
type: 'tool_trace_finish';
trace_id: string;
message_id: string;
chat_id: string;
tool_name: string;
tool_output?: string | null;
latency_ms?: number;
tokens_used?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
error?: string;
outcome?: string;
finished_at: string;
}
// arena frames: battle lifecycle + per-contestant streaming
| {
type: 'battle_started';
@@ -626,8 +663,64 @@ export type WsFrame =
winner_contestant_id?: string | null;
analysis_ready?: boolean;
cross_exam_id?: string;
}
// streaming v2: channel-delta frames. Each carries a monotonic seq for
// out-of-order buffering and a channel discriminator; per-channel payloads
// map to the equivalent legacy frame types after reordering.
| {
type: 'channel_delta';
seq: number;
channel: 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
message_id?: string;
chat_id?: string;
content?: string;
tool_call?: ToolCall;
tool_message_id?: string;
tool_call_id?: string;
output?: unknown;
truncated?: boolean;
error?: string;
reason?: string;
status?: 'running' | 'complete' | 'cancelled' | 'failed';
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
cache_tokens?: number | null;
reasoning_tokens?: number | null;
started_at?: string | null;
finished_at?: string | null;
model?: string | null;
metadata?: MessageMetadata | null;
};
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceResponse {
data: ToolTrace[];
total: number;
limit: number;
offset: number;
}
// token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary {
total_input_tokens: number;
@@ -656,3 +749,21 @@ export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}
// ── Memory browser types ────────────────────────────────────────────
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export interface DailyMemoryEntry extends MemoryEntry {
date: string;
}
export interface DreamEntry {
date: string;
content: string;
}

View File

@@ -156,9 +156,16 @@ function StatsLine({ message }: { message: Message }) {
: `${ctxUsed} ctx`
: null;
const cacheHit = message.cache_tokens;
const reasoning = message.reasoning_tokens;
const cachePart = typeof cacheHit === 'number' && cacheHit > 0 ? `cache ${cacheHit}` : null;
const reasoningPart = typeof reasoning === 'number' && reasoning > 0 ? `think ${reasoning}` : null;
const parts: string[] = [`${tokens} tokens`];
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
if (ctxPart) parts.push(ctxPart);
if (cachePart) parts.push(cachePart);
if (reasoningPart) parts.push(reasoningPart);
return (
<div className="text-[10px] font-mono text-muted-foreground">

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Pin } from 'lucide-react';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
import { RequestReadAccessCard } from './RequestReadAccessCard';
import { MessageListErrorBoundary } from './MessageListErrorBoundary';
interface Props {
messages: Message[];
@@ -142,27 +146,63 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
});
}
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const isNearBottomRef = useRef(true);
const renderedKeysRef = useRef(new Set<string>());
const prefersReducedMotionRef = useRef(false);
const [animateEnabled, setAnimateEnabled] = useState(true);
const [pinMessageId, setPinMessageId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) return hash.slice(5);
}
return null;
});
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
const pinIndex = useMemo(() => {
if (!pinMessageId) return -1;
return renderItems.findIndex(
(item) => item.kind === 'message' && item.message.id === pinMessageId,
);
}, [pinMessageId, renderItems]);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotionRef.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
const handler = () => {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) {
setPinMessageId(hash.slice(5));
} else {
setPinMessageId(null);
}
};
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, []);
const atBottomStateChange = useCallback((atBottom: boolean) => {
isNearBottomRef.current = atBottom;
setAnimateEnabled(atBottom);
}, []);
const scrollToPin = useCallback(() => {
if (pinIndex >= 0 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: pinIndex, align: 'center' });
}
}, [messages]);
}, [pinIndex]);
if (messages.length === 0) {
return (
@@ -173,46 +213,78 @@ export function MessageList({ messages, sessionChats }: Props) {
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
if (item.run.call.name === 'request_read_access') {
return (
<RequestReadAccessCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
<MessageListErrorBoundary>
<div className="flex-1 flex flex-col">
{pinMessageId && pinIndex >= 0 && (
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 bg-primary/10 border-b border-primary/20 text-xs text-primary">
<Pin className="size-3" />
<span>Pinned message</span>
<button
type="button"
onClick={scrollToPin}
className="ml-auto underline hover:no-underline"
>
Jump to pinned
</button>
</div>
)}
<Virtuoso
ref={virtuosoRef}
className="flex-1"
data={renderItems}
followOutput="auto"
overscan={5}
atBottomStateChange={atBottomStateChange}
itemContent={(index, item) => {
const key = item.kind === 'message' ? `msg-${item.message.id}` : item.key;
const isNew = !renderedKeysRef.current.has(key);
if (isNew) renderedKeysRef.current.add(key);
const reducedMotion = prefersReducedMotionRef.current;
const delay = isNew && !reducedMotion ? Math.min(index * 0.04, 0.5) : 0;
const shouldAnimate = isNew && animateEnabled;
return (
<div
className="max-w-[1000px] mx-auto w-full px-6 py-2"
id={item.kind === 'message' ? `msg-${item.message.id}` : undefined}
>
<motion.div
initial={shouldAnimate ? { opacity: 0, y: 8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={delay > 0 ? { duration: 0.2, delay } : { duration: 0 }}
>
{item.kind === 'message' ? (
<MessageBubble
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
) : item.kind === 'tool_run' ? (
item.run.call.name === 'ask_user_input' ? (
<AskUserInputCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : item.run.call.name === 'request_read_access' ? (
<RequestReadAccessCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : (
<ToolCallLine run={item.run} />
)
) : (
<ToolCallGroup runs={item.runs} />
)}
</motion.div>
</div>
);
}}
/>
</div>
</MessageListErrorBoundary>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo } from 'react';
import { Clock, Cpu, Hash, Layers, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Message } from '@/api/types';
interface TurnEntry {
message: Message;
turnNumber: number;
elapsed: string;
toolCallCount: number;
}
interface Props {
messages: Message[];
chatId: string;
onClose: () => void;
onScrollToMessage: (messageId: string) => void;
}
function formatElapsed(startedAt: string | null, finishedAt: string | null): string {
if (!startedAt || !finishedAt) return '—';
const start = new Date(startedAt).getTime();
const end = new Date(finishedAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
const ms = end - start;
if (ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
/**
* SessionTimeline — vertical timeline of assistant turns in a chat.
*
* Renders a side-panel overlay with each turn's model, tokens, duration,
* and tool-call count. Clicking a turn scrolls the main chat to that
* message. The latest turn shows a "Scroll to latest" restore button.
*/
export function SessionTimeline({ messages, onClose, onScrollToMessage }: Props) {
const turns = useMemo<TurnEntry[]>(() => {
const assistantMsgs = messages.filter(
(m) => m.role === 'assistant' && m.status === 'complete',
);
return assistantMsgs.map((message, i) => ({
message,
turnNumber: i + 1,
elapsed: formatElapsed(message.started_at, message.finished_at),
toolCallCount: message.tool_calls?.length ?? 0,
}));
}, [messages]);
const latestTurnId = turns.length > 0 ? turns[turns.length - 1]!.message.id : null;
return (
<div className="absolute inset-y-0 right-0 w-80 z-20 bg-background border-l border-border shadow-xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
<h3 className="text-sm font-semibold">Session Timeline</h3>
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close timeline">
<X size={14} />
</Button>
</div>
{/* Timeline entries */}
<div className="flex-1 overflow-y-auto px-3 py-3">
{turns.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8">
No assistant turns yet.
</div>
) : (
<div className="relative">
{turns.map((turn, i) => {
const isLatest = turn.message.id === latestTurnId;
return (
<div key={turn.message.id} className="relative flex gap-3 pb-4 last:pb-0">
{/* Vertical connector line */}
{i < turns.length - 1 && (
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-border" />
)}
{/* Timeline dot button */}
<button
type="button"
onClick={() => onScrollToMessage(turn.message.id)}
className="relative flex-shrink-0 mt-1 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
aria-label={`Scroll to turn ${turn.turnNumber}`}
>
<div
className={cn(
'size-[22px] rounded-full border-2 flex items-center justify-center',
isLatest
? 'border-primary bg-primary/10'
: 'border-muted-foreground/30 bg-background',
)}
>
<div
className={cn(
'size-2 rounded-full',
isLatest ? 'bg-primary' : 'bg-muted-foreground/50',
)}
/>
</div>
</button>
{/* Content card */}
<div className="flex-1 min-w-0">
<div
className="rounded-lg border border-border bg-card p-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => onScrollToMessage(turn.message.id)}
>
{/* Turn number + latest badge */}
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-foreground">
Turn {turn.turnNumber}
</span>
{isLatest && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full leading-none">
Latest
</span>
)}
</div>
{/* Model name */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<Cpu size={11} className="shrink-0" />
<span className="truncate">{turn.message.model ?? 'Unknown model'}</span>
</div>
{/* Token count with breakdown */}
{turn.message.tokens_used != null && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1 flex-wrap">
<Hash size={11} className="shrink-0" />
<span>{turn.message.tokens_used.toLocaleString()} total</span>
{turn.message.cache_tokens != null && turn.message.cache_tokens > 0 && (
<span className="text-blue-500 dark:text-blue-400">
({turn.message.cache_tokens.toLocaleString()} cache)
</span>
)}
{turn.message.reasoning_tokens != null && turn.message.reasoning_tokens > 0 && (
<span className="text-amber-500 dark:text-amber-400">
({turn.message.reasoning_tokens.toLocaleString()} reasoning)
</span>
)}
</div>
)}
{/* Duration + tool calls */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock size={11} />
{turn.elapsed}
</span>
{turn.toolCallCount > 0 && (
<span className="inline-flex items-center gap-1">
<Layers size={11} />
{turn.toolCallCount} tool call{turn.toolCallCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Restore button for latest turn */}
{isLatest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onScrollToMessage(turn.message.id);
}}
className="mt-1.5 w-full inline-flex items-center justify-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors py-1 rounded-md hover:bg-primary/5"
>
<RefreshCw size={11} />
Scroll to latest
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { api } from '@/api/client';
import type { ToolTrace } from '@/api/types';
interface Props {
chatId: string;
}
// Max latency used as the 100% reference for the bar visualization
const MAX_LATENCY_REF = 30_000; // 30s
function latencyBarWidth(latencyMs: number | null): number {
if (latencyMs == null) return 0;
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
}
function TraceRow({ trace }: { trace: ToolTrace }) {
const [expanded, setExpanded] = useState(false);
const isError = trace.outcome !== null && trace.outcome !== 'success';
const barWidth = latencyBarWidth(trace.latency_ms);
const latencyLabel =
trace.latency_ms != null
? trace.latency_ms >= 1000
? `${(trace.latency_ms / 1000).toFixed(1)}s`
: `${trace.latency_ms}ms`
: null;
return (
<div className="border-b border-border/40 last:border-0">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
>
<span className="shrink-0 text-muted-foreground">
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</span>
<span className="font-medium truncate min-w-0">
{trace.tool_name}
</span>
{isError && (
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
<AlertCircle size={10} />
</span>
)}
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
{latencyLabel ?? '—'}
</span>
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
<span
className="block h-full rounded-full bg-primary/30 transition-all"
style={{ width: `${barWidth * 100}%` }}
/>
</span>
{trace.tokens_used != null && trace.tokens_used > 0 && (
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
{trace.tokens_used}t
</span>
)}
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
c{trace.cache_tokens}
</span>
)}
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
r{trace.reasoning_tokens}
</span>
)}
</button>
{expanded && (
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
<div>
<span className="text-muted-foreground font-medium">Input</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(trace.tool_input, null, 1)}
</pre>
</div>
{trace.tool_output != null && (
<div>
<span className="text-muted-foreground font-medium">Output</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{trace.tool_output.length > 2000
? `${trace.tool_output.slice(0, 2000)}`
: trace.tool_output}
</pre>
</div>
)}
{trace.error != null && (
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
{trace.error}
</div>
)}
</div>
)}
</div>
);
}
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
const [collapsed, setCollapsed] = useState(false);
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
>
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
<span>{toolName}</span>
<span className="text-muted-foreground/60 font-mono tabular-nums">
×{traces.length}
</span>
{totalTokens > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalTokens}t
</span>
)}
{totalLatency > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalLatency >= 1000
? `${(totalLatency / 1000).toFixed(1)}s`
: `${totalLatency}ms`}
</span>
)}
{errorCount > 0 && (
<span className="ml-auto text-destructive text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
</button>
{!collapsed && traces.map((trace) => (
<TraceRow key={trace.id} trace={trace} />
))}
</div>
);
}
export function TraceViewer({ chatId }: Props) {
const [open, setOpen] = useState(false);
const [traces, setTraces] = useState<ToolTrace[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTraces = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.chats.getTraces(chatId);
setTraces(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load traces');
} finally {
setLoading(false);
}
}, [chatId]);
useEffect(() => {
if (open) {
void fetchTraces();
}
}, [open, fetchTraces]);
const groups = useMemo(() => {
const map = new Map<string, ToolTrace[]>();
for (const t of traces) {
const existing = map.get(t.tool_name);
if (existing) {
existing.push(t);
} else {
map.set(t.tool_name, [t]);
}
}
return map;
}, [traces]);
const totalCount = traces.length;
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div className="border-t">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Tool traces</span>
{totalCount > 0 && (
<span className="font-mono tabular-nums text-muted-foreground/60">
{totalCount}
</span>
)}
{errorCount > 0 && (
<span className="text-destructive ml-auto text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
{loading && (
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{open && (
<div className="max-h-80 overflow-y-auto border-t border-border/40">
{loading && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
Loading traces
</div>
)}
{error && (
<div className="px-3 py-2 text-[11px] text-destructive">
{error}
<button
type="button"
onClick={() => void fetchTraces()}
className="ml-2 underline hover:no-underline"
>
retry
</button>
</div>
)}
{!loading && !error && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
No tool traces yet.
</div>
)}
{traces.length > 0 && (
<div className="divide-y divide-border/40">
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
<TraceGroup
key={toolName}
toolName={toolName}
traces={groupTraces}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pencil, Send, X } from 'lucide-react';
import { History, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer';
import { sendToChat } from '@/lib/events';
interface Props {
@@ -25,6 +27,7 @@ interface Props {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0);
const processingRef = useRef(false);
@@ -203,11 +206,41 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
}
}
const handleScrollToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10">
<button
type="button"
onClick={() => setShowTimeline((v) => !v)}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
${showTimeline
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}
`}
aria-label={showTimeline ? 'Close timeline' : 'Open timeline'}
>
<History size={12} />
Timeline
</button>
</div>
)}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
<MessageList messages={chatMessages} sessionChats={sessionChats} />
<TraceViewer chatId={chatId} />
{/* Queued messages */}
{queue.length > 0 && (
<div className="border-t">
@@ -275,6 +308,16 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
messages={chatMessages}
modelContextLimit={modelContextLimit}
/>
{/* Timeline overlay panel */}
{showTimeline && (
<SessionTimeline
messages={chatMessages}
chatId={chatId}
onClose={() => setShowTimeline(false)}
onScrollToMessage={handleScrollToMessage}
/>
)}
</div>
);
}

View File

@@ -16,6 +16,133 @@ interface State {
error: string | null;
}
type Channel = 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
// Per-channel out-of-order frame buffer with contiguous-seq flush logic.
// Stores incoming channel_delta frames and releases them only when seq
// becomes contiguous with the expected next value.
class ChannelBuffer {
private expectedSeq = 0;
private buffer = new Map<number, ChannelDeltaWsFrame>();
push(frame: ChannelDeltaWsFrame): ChannelDeltaWsFrame[] {
if (frame.seq < this.expectedSeq) {
return [];
}
if (frame.seq === this.expectedSeq) {
this.expectedSeq++;
const flushed = [frame];
while (this.buffer.has(this.expectedSeq)) {
const next = this.buffer.get(this.expectedSeq)!;
this.buffer.delete(this.expectedSeq);
this.expectedSeq++;
flushed.push(next);
}
return flushed;
}
this.buffer.set(frame.seq, frame);
return [];
}
get expectedNextSeq(): number {
return this.expectedSeq;
}
get bufferedCount(): number {
return this.buffer.size;
}
reset(seq = 0) {
this.expectedSeq = seq;
this.buffer.clear();
}
}
type ChannelDeltaWsFrame = WsFrame & { type: 'channel_delta' };
// Converts a flushed channel_delta into the equivalent legacy frame so the
// existing applyFrame reducer handles the per-message mutation. Status
// deltas are handled separately (they may need to create the message first
// and apply throughput metadata independently of terminal status).
function channelDeltaToLegacyFrame(delta: ChannelDeltaWsFrame): WsFrame | null {
switch (delta.channel) {
case 'text':
return { type: 'delta', message_id: delta.message_id!, content: delta.content! };
case 'tool_call':
return { type: 'tool_call', message_id: delta.message_id!, tool_call: delta.tool_call! };
case 'tool_result':
return {
type: 'tool_result',
tool_message_id: delta.tool_message_id!,
chat_id: delta.chat_id,
tool_call_id: delta.tool_call_id!,
output: delta.output,
truncated: delta.truncated!,
...(delta.error ? { error: delta.error } : {}),
};
case 'error':
return {
type: 'error',
message_id: delta.message_id,
chat_id: delta.chat_id,
error: delta.error!,
...(delta.reason ? { reason: delta.reason as never } : {}),
};
case 'status':
return null;
}
}
// Apply a flushed status channel_delta to state. Status deltas carry both
// intermediate throughput metadata (tokens_used, ctx_used, model, etc.)
// and optional terminal transitions (complete / cancelled / failed).
function applyStatusDelta(state: State, delta: ChannelDeltaWsFrame): State {
const { message_id, chat_id, status, channel: _c, seq: _s, type: _t, ...meta } = delta;
if (!message_id) return state;
let next = state;
const exists = next.messages.some((m) => m.id === message_id);
if (!exists && status === 'running') {
next = applyFrame(next, {
type: 'message_started',
message_id,
chat_id,
role: 'assistant',
});
}
const metaFields: Record<string, unknown> = {};
if (meta.tokens_used !== undefined) metaFields.tokens_used = meta.tokens_used;
if (meta.ctx_used !== undefined) metaFields.ctx_used = meta.ctx_used;
if (meta.ctx_max !== undefined) metaFields.ctx_max = meta.ctx_max;
if (meta.cache_tokens !== undefined) metaFields.cache_tokens = meta.cache_tokens;
if (meta.reasoning_tokens !== undefined) metaFields.reasoning_tokens = meta.reasoning_tokens;
if (meta.started_at !== undefined) metaFields.started_at = meta.started_at;
if (meta.finished_at !== undefined) metaFields.finished_at = meta.finished_at;
if (meta.model !== undefined) metaFields.model = meta.model;
if (meta.metadata !== undefined) metaFields.metadata = meta.metadata;
if (Object.keys(metaFields).length > 0) {
next = {
...next,
messages: next.messages.map((m) =>
m.id === message_id ? { ...m, ...metaFields } : m,
),
};
}
if (status === 'complete' || status === 'cancelled' || status === 'failed') {
next = applyFrame(next, {
type: 'message_complete',
message_id,
chat_id,
status,
});
}
return next;
}
function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) {
case 'snapshot': {
@@ -33,13 +160,13 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message',
tool_calls: null,
tool_results: null,
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
// skipping the streaming dot for them keeps the UI accurate.
status: frame.role === 'system' ? 'complete' : 'streaming',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
cache_tokens: null,
reasoning_tokens: null,
model: null,
started_at: null,
finished_at: null,
@@ -63,7 +190,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const next = state.messages.map((m) =>
m.id === frame.message_id
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
: m
: m,
);
return { ...state, messages: next };
}
@@ -83,7 +210,7 @@ function applyFrame(state: State, frame: WsFrame): State {
},
status: 'complete' as const,
}
: m
: m,
);
return { ...state, messages: next };
}
@@ -106,6 +233,8 @@ function applyFrame(state: State, frame: WsFrame): State {
tokens_used: null,
ctx_used: null,
ctx_max: null,
cache_tokens: null,
reasoning_tokens: null,
model: null,
started_at: null,
finished_at: null,
@@ -123,22 +252,18 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.cache_tokens !== undefined ? { cache_tokens: frame.cache_tokens } : {}),
...(frame.reasoning_tokens !== undefined ? { reasoning_tokens: frame.reasoning_tokens } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
...(frame.model !== undefined ? { model: frame.model } : {}),
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
// in on this terminal frame so the reducer can attach it
// without waiting for a refetch.
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
}
: m
: m,
);
return { ...state, messages: next };
}
case 'usage': {
// v1.12.2: live throughput. Side-effects into the module-level
// singleton consumed by ChatThroughput; no message-state mutation.
// chat_id is the optional ws-frame field; usage frames always include it.
if (frame.chat_id) {
recordUsage(frame.chat_id, {
completion_tokens: frame.completion_tokens,
@@ -166,10 +291,6 @@ function applyFrame(state: State, frame: WsFrame): State {
return state;
}
case 'error': {
// v1.8.2: when the frame carries a structured reason, stamp it onto the
// failed message's metadata so the bubble can render specifics inline
// (the WS error frame is one-shot; refresh-safe rendering needs the
// value persisted on the message).
const errorMeta = frame.reason
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
: null;
@@ -181,47 +302,53 @@ function applyFrame(state: State, frame: WsFrame): State {
status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}),
}
: m
: m,
)
: state.messages;
return { ...state, messages: next, error: frame.error };
}
case 'compacted': {
// v1.11: side effects (refetch + toast) live in ws.onmessage; the
// reducer just no-ops so TS exhaustiveness is satisfied without
// duplicating async work inside a synchronous reducer.
return state;
}
case 'agent_snapshot': {
return state;
}
case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
case 'flow_run_started':
case 'flow_run_step_updated': {
// Orchestrator frames consumed by OrchestratorPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
case 'battle_started':
case 'contestant_updated':
case 'battle_updated': {
// Arena frames consumed by ArenaPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
case 'channel_delta': {
return state;
}
default: {
return state;
}
}
}
// Matches useUserEvents — exponential backoff with the same ceiling so the
// two channels reconnect on the same cadence after a network handoff.
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
const CHANNEL_STALL_MS = 5000;
export function useSessionStream(sessionId: string | undefined) {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const channelBuffersRef = useRef<Map<Channel, ChannelBuffer>>(new Map());
const lastFrameTimeRef = useRef<Partial<Record<Channel, number>>>({});
// Reset channel buffers when session changes
useEffect(() => {
channelBuffersRef.current = new Map();
lastFrameTimeRef.current = {};
}, [sessionId]);
useEffect(() => {
if (!sessionId) return;
@@ -232,6 +359,73 @@ export function useSessionStream(sessionId: string | undefined) {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
const getLastSeqPerChannel = () => {
const seqs: Partial<Record<Channel, number>> = {};
for (const [ch, buf] of channelBuffersRef.current) {
seqs[ch] = buf.expectedNextSeq;
}
return seqs;
};
const flushDeltaToState = (delta: ChannelDeltaWsFrame) => {
console.error('FDS', delta.channel, 'flushed');
if (delta.channel === 'status') {
setState((s) => applyStatusDelta(s, delta));
} else {
const legacy = channelDeltaToLegacyFrame(delta);
if (legacy) {
setState((s) => applyFrame(s, legacy));
}
}
};
const handleChannelDelta = (frame: ChannelDeltaWsFrame) => {
console.error('HCD', frame.channel, frame.seq, 'bufs', channelBuffersRef.current.size);
const buffers = channelBuffersRef.current;
let buffer = buffers.get(frame.channel);
if (!buffer) {
buffer = new ChannelBuffer();
buffers.set(frame.channel, buffer);
}
const flushed = buffer.push(frame);
if (flushed.length === 0) return;
for (const delta of flushed) {
flushDeltaToState(delta);
}
let emittedRefresh = false;
for (const delta of flushed) {
if (delta.channel === 'status' && (delta.status === 'complete' || delta.status === 'cancelled' || delta.status === 'failed')) {
emittedRefresh = true;
}
}
if (emittedRefresh) {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
lastFrameTimeRef.current[frame.channel] = Date.now();
};
// Periodic channel stall check: if any channel has buffered frames
// but no progress for 5s, force a snapshot refetch.
let stallTimer: ReturnType<typeof setInterval> | null = null;
const startStallTimer = () => {
stallTimer = setInterval(() => {
const now = Date.now();
for (const [channel, buffer] of channelBuffersRef.current) {
if (buffer.bufferedCount === 0) continue;
const lastTime = lastFrameTimeRef.current[channel as Channel] ?? 0;
if (now - lastTime >= CHANNEL_STALL_MS) {
buffer.reset();
sessionEvents.emit({ type: 'refetch_messages' });
}
}
}, 1000);
};
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -242,13 +436,16 @@ export function useSessionStream(sessionId: string | undefined) {
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null }));
// Mid-stream reconnection protocol: send last known seq per channel
// so the server can replay deltas or fall back to a full snapshot.
const lastSeq = getLastSeqPerChannel();
ws.send(JSON.stringify({ type: 'reconnect', lastSeqPerChannel: lastSeq }));
startStallTimer();
};
ws.onmessage = (ev) => {
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
// frames are logged and dropped. WsFrameSchema is the runtime guard;
// the hand-maintained WsFrame type stays as the narrowed dev-time
// shape (Zod uses OpaqueObject for nested types like Message[]). One
// cast bridges the two.
let raw: unknown;
try {
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
@@ -266,13 +463,14 @@ export function useSessionStream(sessionId: string | undefined) {
}
try {
const frame = validated.data as unknown as WsFrame;
// v1.11: on a compaction completion, re-fetch the message list so
// the new summary row + the cohort of compacted_at-stamped older
// rows render correctly. We dispatch the fresh list as a synthetic
// 'snapshot' frame so the reducer's existing path handles state
// replacement (no need for a parallel "refetched" path).
// The toast is purely UX feedback; missing it would still leave
// the chat in a valid state.
if (frame.type === 'channel_delta') {
console.error('RAW_PARSE', JSON.stringify(validated.data).slice(0, 200));
console.error('CD', frame.channel, frame.seq, JSON.stringify(frame).slice(0, 80));
handleChannelDelta(frame);
return;
}
if (frame.type === 'compacted') {
toast.success('Context compacted to free space');
void api.messages
@@ -285,8 +483,9 @@ export function useSessionStream(sessionId: string | undefined) {
});
return;
}
setState((s) => applyFrame(s, frame));
// Trigger git diff refresh after each completed assistant turn.
if (frame.type === 'message_complete') {
sessionEvents.emit({ type: 'git_diff_refresh' });
}
@@ -294,15 +493,18 @@ export function useSessionStream(sessionId: string | undefined) {
console.warn('bad ws frame', err);
}
};
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
// UI; this channel just reconnects silently on the same backoff.
ws.onerror = () => {
try { ws.close(); } catch {}
};
ws.onclose = () => {
if (unmounted) return;
setState((s) => ({ ...s, connected: false }));
if (stallTimer) {
clearInterval(stallTimer);
stallTimer = null;
}
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
@@ -314,6 +516,7 @@ export function useSessionStream(sessionId: string | undefined) {
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (stallTimer) clearInterval(stallTimer);
const ws = wsRef.current;
wsRef.current = null;
if (ws) try { ws.close(); } catch {}

View File

@@ -1,44 +0,0 @@
# v2.8 — boocontext sidecar container.
# Multi-stage build: Go shim from golang:1.24-alpine, boocontext MCP aggregator
# from node:20-alpine, then an alpine:3.20 runtime holding both.
#
# The shim spawns boocontext as a child MCP process over stdio NDJSON,
# translating HTTP requests to MCP tools/call.
#
# To stage the fork source for a Docker build:
# tar -czf codecontext/fork.tar.gz -C /opt/forks/boocontext \
# --exclude=.git --exclude=node_modules --exclude=dist
# Stage 1: Go shim builder
FROM golang:1.24-alpine AS shim-builder
WORKDIR /build/shim
RUN apk add --no-cache ca-certificates
COPY go.mod ./
COPY shim.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
# Stage 2: boocontext MCP builder (pnpm project)
FROM node:20-alpine AS boocontext-builder
WORKDIR /build/boocontext
RUN apk add --no-cache git python3 make g++ ca-certificates
RUN npm install -g pnpm@9 --silent
COPY fork.tar.gz /build/fork.tar.gz
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
WORKDIR /build/boocontext
RUN pnpm install --frozen-lockfile && pnpm run build
# Stage 3: Runtime
FROM alpine:3.20
# uv intentionally not installed — container network blocks astral.sh.
# tree-sitter-analyzer child server (uvx) won't start in-container, but
# boocontext logs a graceful warning; TSA-backed tools fall through.
RUN apk add --no-cache ca-certificates nodejs
COPY --from=shim-builder /build/shim-bin /usr/local/bin/shim
COPY --from=boocontext-builder /build/boocontext/dist /usr/local/lib/boocontext/dist
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
COPY --from=boocontext-builder /build/boocontext/package.json /usr/local/lib/boocontext/package.json
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD wget -qO- http://localhost:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/shim"]

31
codecontext/README.md Normal file
View File

@@ -0,0 +1,31 @@
# codecontext — Go sidecar (DEPRECATED)
> **Deprecated** (Phase 4, Domain 2, v2.8.14).
>
> Superseded by the **boocontext MCP server** (`apps/coder`). Do not add new
> callers. The 16 codecontext tool wrappers still use this sidecar via HTTP at
> `http://codecontext:8080/v1/{toolName}` for backward compatibility.
## Migration path
1. Existing tool wrappers in `apps/server/src/services/tools/codecontext/` route
through `callCodecontext()` in `codecontext_client.ts`, which calls this
Go sidecar over HTTP.
2. New callers should use the boocontext MCP server instead (reachable via the
`boocontext` tool wrappers).
3. After all callers have migrated, remove this directory, the `codecontext`
service block from `docker-compose.yml`, and the
`codecontext_client.ts`/`factory.ts` files.
## What it does
A Go HTTP shim wrapping the boocontext MCP server's stdio interface. Provides
code-graph analysis (symbols, callers, callees, file overview, etc.) over a
REST API at `/v1/{toolName}`.
## Files
- `shim.go` — HTTP server that wraps the boocontext MCP stdio process
- `Dockerfile` — container build
- `fork.tar.gz` — vendored boocontext source (gitignored)
- `.codecontextignore.template` — default ignore patterns deployed per project

View File

@@ -6,7 +6,7 @@ Operating rules for every agent in this registry. Full procedures live in the `c
**Worktrees** — Isolate work in a worktree when it is parallel to in-progress work, risky/experimental, a hotfix interrupting other work, or splits into independent units — just create when clear, propose in one line when ambiguous, skip quick/small single-stream work. Branch from a stable base (default branch); worktrees persist (never auto-remove or auto-merge); they isolate code state, not runtime (ports/DBs/services still collide). Full heuristic: invoke `using-worktrees`.
**Sampling knobs** — Each `## Name` frontmatter block accepts these per-agent sampler fields, threaded into the llama-swap chat-completion request: `temperature`, `top_p`, `top_k`, `min_p`, `presence_penalty`, and (v2.6) `top_n_sigma`, `dry_multiplier`, `dry_base`, `dry_allowed_length`, `dry_penalty_last_n`. The `top_n_sigma` + `dry_*` repetition family curb the doom-loop-prone local model. Omit a field to leave it at the server default. Example: `top_n_sigma: 1.0`, `dry_multiplier: 0.8`, `dry_base: 1.75`, `dry_allowed_length: 2`, `dry_penalty_last_n: -1` (-1 = whole context).
**Sampling knobs** — Each `## Name` frontmatter block accepts these per-agent sampler fields, threaded into the llama-swap chat-completion request: `temperature`, `top_p`, `top_k`, `min_p`, `presence_penalty`, and (v2.6) `top_n_sigma`, `dry_multiplier`, `dry_base`, `dry_allowed_length`, `dry_penalty_last_n`. The `top_n_sigma` + `dry_*` repetition family curb the doom-loop-prone local model. Omit a field to leave it at the server default. Example: `top_n_sigma: 1.0`, `dry_multiplier: 0.8`, `dry_base: 1.75`, `dry_allowed_length: 2`, `dry_penalty_last_n: -1` (-1 = whole context). DeepSeek V4 models also accept `reasoning_effort` (low/medium/high/xhigh/max); omit to disable thinking mode. Example: `reasoning_effort: 'high'`.
**Reasoning budget** — To cap a reasoning model's thinking tokens, pass `--reasoning-budget` through `llama_extra_args` (already permitted by the deny-list validator; routes the agent to llama-sidecar). Example frontmatter line: `llama_extra_args: ["--reasoning-budget", "2048"]`. This is a sidecar process flag, not a chat-completion body param — distinct from the sampling knobs above.

View File

@@ -7,7 +7,6 @@ services:
- "100.114.205.53:9500:3000"
env_file: .env
environment:
CODECONTEXT_URL: http://codecontext:8080
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
BOOCODER_URL: http://100.114.205.53:9502
@@ -91,41 +90,6 @@ services:
networks:
- boocode_net
# v1.12 Track B: codecontext sidecar. Stdio MCP server wrapped by a small
# HTTP shim (see ./codecontext/). No host port — reached from boocode at
# http://codecontext:8080 over the boocode_net bridge.
#
# Mounts /opt:/opt:ro (not just /opt/projects:ro): BooCode projects live
# at /opt/<slug> on the host, not exclusively under /opt/projects. The
# mount must cover anywhere a project.path could resolve to. Read-only
# because codecontext only analyzes — never writes. The model can't
# arbitrarily set target_dir to a sensitive subtree because the B.2
# wrappers validate target_dir against project.path before calling the
# shim, and the shim isn't reachable from outside boocode_net.
codecontext:
build:
context: ./codecontext
container_name: boocode_codecontext
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
environment:
CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js --mcp
TYPE_INJECT_MCP_PATH: /opt/type-inject/packages/mcp/dist/index.js
TREE_SITTER_MCP_CMD: uvx
TREE_SITTER_MCP_ARGS: --from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp
networks:
- boocode_net
volumes:
- /opt:/opt:ro
- /opt/forks:/opt/forks:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
boocode_pgdata:

View File

@@ -0,0 +1,107 @@
# Paseo-like Orchestrator — Trace Observability, Dynamic Workflows & Agent Runtime
**Status:** Proposed
**Epic:** paseo-orchestrator
**Depends on:** v2.7.17-orchestrator
## Why
BooCode's Orchestrator (v2.7.17) runs deterministic Han analysis flows — but it's a fixed pipeline, not a general-purpose agent runtime. Every tool call is opaque: no timing, no cost breakdown, no replay. Sessions evaporate on browser refresh. Workflows are hardcoded. Subagents block until completion. And there's zero visibility into cache efficiency on DeepSeek — despite prompt caching being a major cost lever.
The current architecture treats the LLM as a black box and the agent as a one-shot transaction. To move from "read-only chat" to a **Paseo-style thin-client orchestration layer**, BooCode needs five capabilities that compound on each other:
1. **Observability** — Every tool call timed, logged, and live-streamed. Without it, debugging agent behavior is guesswork.
2. **Persistence** — Agent state survives browser refresh. Active sessions resume where they left off.
3. **Dynamic Workflows** — User-authored JS scripts using `agent()`, `parallel()`, `pipeline()` instead of hardcoded flows. Hash-based caching skips completed steps on re-run.
4. **Background Subagents**`spawn_subagent` returns immediately, results collected later. Unlocks parallel research, long-running analyses, and notification-based workflows.
5. **Multi-modal + Cache Shape** — Image attachments forwarded to DeepSeek's vision API, plus per-turn cache hit rate visualization to close the cost feedback loop.
Each phase is independently valuable; together they transform BooCode from a chat UI into a durable agent execution platform.
## What Changes
### Phase 1: Trace System + Observability (3-4 days)
1. **Create `tool_traces` DB table** — id, session_id, chat_id, turn_number, tool_name, input, output, started_at, finished_at, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error, outcome. Applied idempotently via `applySchema()`.
2. **Add `tool_trace` WS frame** — new WsFrame variant in `@boocode/contracts` published by the server when a tool call starts and completes. Frontend receives live timing deltas via `useSessionStream`.
3. **Instrument `tool-phase.ts`** — wrap `executeToolCall` with `clock_timestamp()` start/end, extract token counts from LLM response metadata, publish `tool_trace` frames on start (with input) and finish (with output + metrics).
4. **Add GET `/api/chats/:id/traces`** — paginated endpoint returning trace rows ordered by turn_number + started_at. Supports cursor-based pagination for large sessions.
5. **Build trace viewer pane** — collapsible tree per turn, timing bars showing latency relative to turn duration, expand/collapse per tool call showing input/output. Integrates into the existing multi-pane workspace alongside chat, coder, and orchestrator panes.
### Phase 2: Session Persistence + Resume (2-3 days)
6. **Serialize agent state to DB** — on each turn boundary (before and after tool call loop), snapshot the active `AgentSession` state (provider config, turn history, pending tool calls) to a JSONB column in `agent_sessions`. Uses `clock_timestamp()` for ordering.
7. **Restore on WS reconnect** — when `snapshot` frame arrives on reconnection, check for a persisted `AgentSession` in `in_progress` or `awaiting_input` state. Rehydrate the coder pane to match the persisted turn, tool call, and pending state.
8. **Agent session timeline view** — a timeline component in the coder pane showing the history of all turns in the current agent session. Each turn shows start time, tool count, token usage, cache hit rate. Clicking a turn scrolls to that point in the conversation.
### Phase 3: Dynamic Workflow Engine (5-7 days)
9. **Create `isolated-vm` sandbox** — restricted JS execution environment for workflow scripts. No `require`, `fs`, `net`, `child_process`. Only the workflow API surface exposed. Token budget enforcement kills runaway scripts.
10. **Implement workflow API primitives**`agent(id, { prompt, model, tools, budget })` defines a sub-agent; `parallel([agent1, agent2])` runs N agents concurrently with a shared token budget; `pipeline([step1, step2])` chains agents sequentially; `phase(name, { agents, budget })` groups agents under a named phase; `budget(limit)` sets token or step limits; `log(msg)` emits structured workflow log. Compatible with Claude Code workflow script format.
11. **Workflow file discovery** — scan `.boocode/workflows/*.js` (project-local), `~/.boocode/workflows/*.js` (global), and a built-in catalog directory. Each file exports a `workflow` object with `{name, description, run}`. Discovery runs on server start and on file change (optional watch mode).
12. **Workflow manager + built-in catalog**`WorkflowManager` class with `list()`, `get(name)`, `run(workflow, args)`, `cancel(runId)`, `status(runId)`. Concurrency limits (configurable max concurrent runs), token budgets per run. Built-in catalog includes: `deep-research` (parallel source search → per-source analysis → synthesis), `multi-review` (code health + security + standards reviews in parallel), `plan-verify` (generate plan → verify plan → generate tasks), `bounty-hunt` (parallel vulnerability scanning with different focuses).
13. **Workflow resumability** — SHA-256 hash of each agent spec (prompt + options). Before executing an agent, check if a completed result exists with the same hash. Skip cached agents, only execute new/changed ones. In-memory LRU cache for current session, optional DB persistence for cross-session reuse.
14. **Workflow UI integration** — extend the existing Orchestrator panel (used for Han flows) to support dynamic workflows. Workflow selector dropdown, live run pane with step-by-step progress, cancel button, log output stream, per-agent timing. Reuses the same run-pane component pattern.
### Phase 4: Background Subagents (2-3 days)
15. **Background task queue** — uses the existing `tasks` table with a new `background` type. `spawn_subagent` tool creates a task row and returns immediately. A background worker picks up the task and executes it without blocking the calling agent.
16. **`subagent_status` + `subagent_result` tools** — `subagent_status(task_id)` returns `running|completed|failed` with optional progress info. `subagent_result(task_id)` returns the full output when completed. Polling-based (no WS push for background tasks initially).
17. **Background agent pane** — new pane type showing running/completed background agents. Each entry shows name, status, duration, progress. Completed entries show a "View Result" action. Notifications hook into the existing notification system (toast on completion, badge count for active tasks).
### Phase 5: Multi-modal + Cache Shape (2-3 days)
18. **Image/file attachment pipeline** — accept file uploads (drag-drop or file picker), store on tmpfs with a reference in the message row. Forward to DeepSeek's multimodal API as base64-encoded image parts. Size limit enforcement (configurable, default 20MB per attachment).
19. **Image render in message bubble** — render attached images inline in the chat message bubble. Lightbox on click for expanded view. Thumbnail generation for large images to keep chat scrolling performant.
20. **Cache shape telemetry** — extract `prompt_cache_hit_tokens` from DeepSeek provider metadata on each turn. Break down by segment: system prompt, tool schemas, conversation history. Store in `tool_traces` columns and/or a dedicated `cache_stats` table.
21. **Cache hit rate visualization** — per-turn cache hit bar in the trace viewer (showing cached vs non-cached tokens). Cumulative cache hit rate in the session footer. Highlight when a turn achieves high cache reuse (green indicator) or unusually low (yellow/red).
## Non-Goals
- No changes to the existing Han flow orchestrator (runs alongside dynamic workflows)
- No removal of existing agent dispatch paths (PTY, ACP, Claude SDK — dynamic workflows are additive)
- No distributed execution (all orchestration is single-node)
- No persistent workflow file watching (manual reload or server restart to pick up new workflows)
- No workflow editing UI (workflows are authored as JS files)
## Capabilities
### New Capabilities
- **Tool trace viewer** — every tool call with timing, token costs, cache breakdown, expandable input/output
- **Agent session resume** — browser refresh preserves active agent state
- **Dynamic workflows** — user-authored JS scripts with `agent()/parallel()/pipeline()` API
- **Workflow resumability** — hash-based step caching skips completed agents on re-run
- **Built-in workflow catalog** — deep-research, multi-review, plan-verify, bounty-hunt
- **Background subagents** — non-blocking spawn with deferred result collection
- **Multi-modal support** — image attachments forwarded to DeepSeek vision API
- **Cache shape telemetry** — per-turn and cumulative cache hit rate visualization
### Modified Capabilities
- **Orchestrator panel** — extended from fixed Han flows to dynamic workflow selection and streaming run pane
- **tool-phase.ts** — instrumented with start/end timing and trace publishing
- **WsFrame contract** — new `tool_trace` frame variant
- **tasks table** — extended with `background` type for async subagent execution
## Metrics
- Tool call observability: 0% → 100% of calls traced with timing
- Session continuity: lost on refresh → preserved on reconnect
- Workflow authoring: hardcoded → user-authored JS scripts
- Workflow re-run efficiency: 0% cache → hash-based step reuse
- Background execution: blocking only → blocking + non-blocking
- Cache visibility: 0% → per-turn + cumulative hit rate
- Multi-modal: text-only → text + image attachments

View File

@@ -0,0 +1,230 @@
# Tasks — Paseo-like Orchestrator
## Phase 1: Trace System + Observability (5 tasks)
### 1. Create tool_traces DB table + migration
Add `tool_traces` table to `apps/server/src/schema.sql`:
- Columns: id (UUID PK), session_id (UUID FK → sessions), chat_id (UUID FK → chats), turn_number (int), tool_name (text), input (jsonb), output (jsonb), started_at (timestamptz), finished_at (timestamptz), latency_ms (int), tokens_used (int), cache_tokens (int), reasoning_tokens (int), error (text), outcome (text)
- Index on (chat_id, turn_number, started_at) for trace queries
- Index on (session_id) for session-level aggregation
- Applied idempotently via `applySchema()` — wrap in `CREATE TABLE IF NOT EXISTS`
**Verification**: `psql` shows `tool_traces` table with all columns and indexes. Schema re-run is no-op.
### 2. Add tool_trace WS frame + contracts schema
Add `tool_trace` frame to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts`:
- Frame types: `tool_trace:start` (tool_name, input, started_at) and `tool_trace:complete` (tool_name, output, latency_ms, tokens_used, cache_tokens, reasoning_tokens, error)
- Add to `InferenceFrame` loose union in `apps/server/src/services/inference/turn.ts`
- Add to strict `WsFrame` discriminated union in `apps/web/src/api/types.ts`
- Rebuild contracts: `pnpm -C packages/contracts build`
**Verification**: tsc --noEmit passes. WS client receives `tool_trace:start` and `tool_trace:complete` frames.
### 3. Instrument tool-phase.ts with start/end timing
Update `apps/server/src/services/tools/tool-phase.ts`:
- Before `executeToolCall`: record `clock_timestamp()` as start, publish `tool_trace:start` frame with tool_name and input
- After `executeToolCall`: record `clock_timestamp()` as finish, compute latency_ms, extract token counts from response metadata, INSERT into `tool_traces` table, publish `tool_trace:complete` frame
- Handle errors: on thrown error, publish `tool_trace:complete` with error field set, set outcome='error'; on success, outcome='success'
- Use `sql.json(input as never)` for JSONB columns — no double-serialization
**Verification**: Every tool call produces a `tool_traces` row with correct latency_ms and outcome. WS client receives both start and complete frames.
### 4. Add GET /api/chats/:id/traces endpoint
Create `apps/server/src/routes/traces.ts`:
- `GET /api/chats/:id/traces` — paginated, ordered by (turn_number, started_at)
- Query params: `cursor` (opaque cursor for keyset pagination), `limit` (default 50, max 200), `turn_number` (optional filter to single turn)
- Returns `{traces: Trace[], next_cursor: string | null}`
- Register in Fastify router with `chatOwnershipPreHandler` guard
**Verification**: `curl /api/chats/:id/traces` returns paginated trace rows. Turn filter returns only matching traces.
### 5. Build trace viewer frontend component
Create `apps/web/src/components/TraceViewer.tsx` (and supporting files):
- Collapsible tree grouped by turn_number
- Per tool call row: tool_name badge, latency bar (relative bar width, color-coded: green <1s, yellow <5s, red ≥5s), token count, expand/collapse chevron
- Expanded view: tool input (JSON formatted), tool output (JSON formatted), error message if any
- Fetch traces from `/api/chats/:id/traces` on pane mount, paginate on scroll
- Integrate as a new pane option in the multi-pane workspace (existing pane registry)
**Verification**: Trace viewer loads, groups by turn, shows timing bars, expands/collapses tool calls. Pagination works for sessions with 50+ traces.
## Phase 2: Session Persistence + Resume (3 tasks)
### 6. Serialize agent state to DB on turn boundaries
Modify `apps/coder` agent dispatch:
- On each turn boundary (after LLM response, before next tool call loop), serialize `AgentSession` state to `agent_sessions` table
- Persist: provider config, turn history, pending tool calls, current phase, token budget remaining
- Use JSONB column for the snapshot state, `clock_timestamp()` for last_update
- Guard against rapid consecutive saves (debounce 200ms)
**Verification**: Agent session state is written to `agent_sessions` after each LLM turn. JSONB snapshot contains all fields needed for resume.
### 7. Restore state on WS reconnect
Update `apps/server/src/services/ws.ts`:
- On `snapshot` frame from a reconnecting client, check for `AgentSession` in `in_progress` or `awaiting_input` state
- If found, rehydrate the coder pane: restore provider config, replay pending tool calls, set turn history
- Publish a `session_restored` frame with the restored state metadata
- Client-side: `useSessionStream` handles `session_restored` by resetting pane state to match
**Verification**: Refresh browser mid-agent-session → after reconnect, the coder pane shows the same turn state, pending tool calls, and conversation history.
### 8. Agent session timeline view
Add timeline component to the coder pane:
- Horizontal timeline showing all turns in the current agent session
- Each turn entry: turn number, start time, tool call count, token usage, cache hit rate
- Active turn highlighted, past turns dimmed
- Clicking a past turn scrolls the conversation to that turn and collapses later turns
- Fetch turn metadata from existing session data (no new endpoint needed)
**Verification**: Timeline shows all turns. Clicking a turn scrolls to it. Active turn is highlighted.
## Phase 3: Dynamic Workflow Engine (6 tasks)
### 9. Create isolated-vm workflow sandbox
Create `apps/server/src/services/workflow/sandbox.ts`:
- Use `isolated-vm` npm package to create a V8 isolate for each workflow run
- No `require`, `fs`, `net`, `child_process` accessible in the sandbox
- Expose only the workflow API surface (`agent`, `parallel`, `pipeline`, `phase`, `budget`, `log`, `args`)
- Token budget enforcement: inject a step counter, throw when budget exceeded
- Timeout: 30s default, configurable per workflow
- Error boundary: caught exceptions produce structured error results instead of crashing the worker
- Add `isolated-vm` to `apps/server/package.json` dependencies
**Verification**: Workflow script that calls `agent()` runs without error. Script trying `require('fs')` throws a sandbox violation. Run exceeding budget is killed with a clear message.
### 10. Implement agent/parallel/pipeline primitives
Create `apps/server/src/services/workflow/api.ts`:
- `agent(id, { prompt, model?, tools?, budget? })` — registers a sub-agent. Returns an object with `.run(input)` that dispatches the agent through the existing agent dispatch system and returns result.
- `parallel([agents], { budget? })` — runs all agents concurrently. Returns when all complete (or any fails). Shared token budget across parallel agents. Uses `Promise.allSettled` for resilience.
- `pipeline([steps], { budget? })` — runs steps sequentially. Each step receives the previous step's output. Steps can be `agent()` results or inline functions.
- `phase(name, { agents, budget })` — groups agents under a named phase. Phases can have their own budget. Results are namespaced by phase name.
- `budget(limit)` — sets token or step limits. Returns a budget object consumed by agent/parallel/pipeline.
- `log(msg)` — emits a structured log entry tagged with current phase/agent context. Published as WS frame to the Orchestrator pane.
- `args` — the input arguments passed to `workflow.run(args)`.
**Verification**: A test workflow using `agent()`, `parallel()`, and `pipeline()` executes correctly. Logs appear in the output stream. Token budgets are enforced.
### 11. Workflow file discovery system
Create `apps/server/src/services/workflow/discovery.ts`:
- Scan `.boocode/workflows/*.js` (project root, relative to `PROJECT_ROOT_WHITELIST`)
- Scan `~/.boocode/workflows/*.js` (global, `os.homedir()`)
- Scan `data/workflows/` (built-in catalog)
- Each file must export a `workflow` object: `{name, description, run(args) => {...}}`
- Validate the workflow object at discovery time: required fields, run must be a function
- On server start, run full discovery. Cache results in a `Map<name, Workflow>`.
- Log discovered workflows with name + description at `info` level
**Verification**: Placing a valid `.boocode/workflows/test.js` file makes the workflow appear in `WorkflowManager.list()`. Invalid workflow files are logged as warnings and skipped.
### 12. Workflow manager + built-in catalog
Create `apps/server/src/services/workflow/manager.ts`:
- `WorkflowManager` singleton class:
- `list()` — returns all discovered workflows with name, description, and arg schema
- `get(name)` — returns a workflow by name
- `run(workflow, args)` — creates a sandbox, injects args, executes `workflow.run()`. Returns a runId (UUID).
- `cancel(runId)` — terminates the sandbox, marks run as cancelled
- `status(runId)` — returns run status: `pending|running|completed|failed|cancelled`, with progress info
- Concurrency limit: configurable via `WORKFLOW_MAX_CONCURRENT` env var (default 3)
- Token budget: configurable via `WORKFLOW_DEFAULT_BUDGET` env var (default 100_000 tokens)
- Run state tracked in-memory with optional DB persistence
Built-in workflows in `data/workflows/`:
- `deep-research` — parallel source search → per-source analysis → synthesis report
- `multi-review` — run code health + security + standards reviews in parallel, merge findings
- `plan-verify` — generate implementation plan → verify plan → generate work items
- `bounty-hunt` — parallel vulnerability scans with different focus areas (injection, auth, crypto, business logic)
**Verification**: `list()` returns built-in workflows. `run()` executes a workflow and returns runId. `status()` reflects progress. `cancel()` stops execution cleanly.
### 13. Workflow resumability (hash-based cache)
Create `apps/server/src/services/workflow/cache.ts`:
- Compute SHA-256 hash of each agent spec: `crypto.createHash('sha256').update(JSON.stringify({prompt, options})).digest('hex')`
- Before executing an agent, check in-memory LRU cache for existing result matching the hash
- Hit: return cached result, emit `log('cached', agentId, hash)` — no actual dispatch
- Miss: execute agent, store result in cache keyed by hash
- LRU eviction: `WORKFLOW_CACHE_SIZE` env var (default 100 entries)
- Optional DB persistence: `workflow_cache` table with `hash`, `result`, `created_at` — cross-session reuse
- Re-run detection: identical workflow with same args → all agents skipped
- Partial re-run: changed args → only changed agents re-execute, unchanged ones read from cache
**Verification**: First run of a workflow executes all agents. Second run with identical args skips all agents (logs show 'cached'). Run with modified args for one agent only re-executes that agent.
### 14. Workflow UI integration with Orchestrator panel
Extend `apps/web/src/components/Orchestrator/`:
- Add workflow selector dropdown listing workflows from `WorkflowManager.list()`
- Add "Run Workflow" button that opens workflow args editor (JSON or form)
- Extend existing run pane to show workflow steps with per-agent progress
- Live log stream from workflow `log()` calls, displayed in a scrollable log view
- Cancel button for running workflows
- Resumability indicator: "3/5 steps cached — skipping" when hash cache hits
- Fetch workflow list via new API endpoint or WS message (add `GET /api/orchestrator/workflows`)
**Verification**: Workflow selector lists built-in workflows. Running a workflow shows step-by-step progress in the run pane. Cancelling a running workflow works. Cached steps show "skipped" indicator.
## Phase 4: Background Subagents (3 tasks)
### 15. Background task queue + spawn_subagent tool
Modify `apps/coder/` and `apps/server/`:
- Extend `tasks` table usage with a new task type marker for background subagent tasks
- Create `spawn_subagent` tool in `apps/server/src/services/tools/`:
- Schema: `{prompt, model?, tools?, budget?, metadata?}`
- Creates a `tasks` row with state=`pending`, type=`background_subagent`
- Returns `{task_id, status: 'pending'}` immediately — does NOT block
- Background worker loop: polls `tasks` table for `background_subagent` tasks in `pending` state, picks one up, executes it via existing agent dispatch, writes result back to tasks row on completion
- Max concurrency: `BACKGROUND_MAX_CONCURRENT` env var (default 2)
- Worker polls interval: 1s (configurable)
**Verification**: Calling `spawn_subagent` returns immediately with a task_id. The task eventually completes with a result in the tasks table. Multiple background tasks run concurrently up to the concurrency limit.
### 16. subagent_status + subagent_result tools
Create two tools in `apps/server/src/services/tools/`:
- `subagent_status(task_id)`:
- Schema: `{task_id}`
- Returns: `{task_id, status: 'pending'|'running'|'completed'|'failed', progress?: string, started_at?, finished_at?}`
- Queries `tasks` table for the status
- `subagent_result(task_id)`:
- Schema: `{task_id}`
- Returns: `{task_id, status, result?: json, error?: string}`
- Only returns result when status='completed'; returns empty result otherwise with a message
- Updates task state to `read` on successful result retrieval (optional)
**Verification**: Calling `subagent_status` on a running task returns 'running'. Calling `subagent_result` on a completed task returns the full result. Calling `subagent_result` on a pending task returns a clear "not ready yet" message.
### 17. Background agent pane
Create `apps/web/src/components/BackgroundAgentPane.tsx`:
- New pane type showing running, completed, and failed background subagents
- Each entry: agent name/description, status badge, duration (elapsed or total), progress indicator
- Running entries: progress bar (if available), cancel button
- Completed entries: "View Result" action that opens a modal or inline view with the full output
- Failed entries: error message, "Retry" action
- Badge counter on pane tab showing number of running tasks
- Poll status every 2s for running entries, stop polling on completion
- Register in pane registry alongside existing pane types
**Verification**: Background pane shows spawning tasks as "pending", transitioning to "running", then "completed"/"failed". "View Result" shows the full output. Badge counter reflects active running tasks.
## Phase 5: Multi-modal + Cache Shape (4 tasks)
### 18. Multi-modal attachment pipeline
Add file upload support:
- Accept file uploads via drag-drop or file picker in the message input area
- Store uploaded files on tmpfs (`/tmp/boocode-uploads/` by default, configurable via `UPLOAD_DIR`)
- Reference attachments in message row via `message_parts` with `type='image'` and a `url` pointing to the tmpfs path
- Forward to DeepSeek API: encode image as base64 data URI, send as multimodal content part in the user message
- Supported formats: png, jpg, jpeg, gif, webp
- Size limit: 20MB default, configurable via `MAX_ATTACHMENT_SIZE_MB` env var
- Server-side cleanup: delete tmpfs files after message is fully processed or on a periodic sweep
**Verification**: Uploading an image creates a file on tmpfs and a referenced `message_parts` row. DeepSeek API call includes the image as a base64 content part. Error on files over size limit.
### 19. Image render in message bubble
Update message rendering in `apps/web/src/components/MessageBubble.tsx`:
- Detect `message_parts` with `type='image'` in the message content
- Render attached images inline in the chat bubble, below the text content
- Thumbnail: max 300px wide, aspect-ratio preserved, rounded corners
- Lightbox: clicking the thumbnail opens a full-size overlay with close button
- Loading state: skeleton placeholder while image loads from tmpfs URL
- Error state: broken image placeholder with retry option
- Clean layout: images displayed in a grid (1-2 columns depending on count)
**Verification**: Chat messages with image attachments render inline thumbnails. Clicking opens lightbox. Large images are thumbnailed. Broken images show error state.
### 20. Cache shape telemetry data pipeline
Extract and store cache metrics:
- In the DeepSeek provider response handler, extract `prompt_cache_hit_tokens` and `prompt_cache_miss_tokens` from the API response metadata
- Break down cache segments: system prompt tokens, tool schema tokens, conversation history tokens (approximate by measuring each segment length)
- Store cache metrics in `tool_traces.cache_tokens` column (already created in Phase 1)
- Optionally create a `cache_stats` table for per-segment breakdown: `{turn_id, segment_name, hit_tokens, miss_tokens}`
- Expose via existing traces API (cache fields already part of the Trace schema)
**Verification**: After a DeepSeek call, `tool_traces` row has `cache_tokens` populated. Cache segment breakdown is available when querying traces.
### 21. Cache shape visualization in trace viewer
Update the TraceViewer component with cache metrics:
- Per-turn cache hit bar: horizontal stacked bar showing cached (green) vs non-cached (gray) tokens
- Hit rate percentage displayed as a badge next to token count
- Cumulative cache hit rate in the session footer: "Cache hit rate: 67% (45K/67K tokens)"
- Color coding: green ≥60%, yellow 30-59%, red <30%
- Tooltip on hover showing segment breakdown if available
- Animate transitions when new trace data arrives
**Verification**: Trace viewer shows cache hit/miss bars per turn. Cumulative rate in footer updates as new traces load. Color coding matches thresholds.

View File

@@ -116,6 +116,8 @@ export const MessageCompleteFrame = z.object({
tokens_used: z.number().int().nonnegative().nullable().optional(),
ctx_used: z.number().int().nonnegative().nullable().optional(),
ctx_max: z.number().int().positive().nullable().optional(),
cache_tokens: z.number().int().nonnegative().nullable().optional(),
reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
started_at: IsoTimestamp.nullable().optional(),
finished_at: IsoTimestamp.nullable().optional(),
// nullable: external-coder turns carry task.model, which is null when no
@@ -353,7 +355,7 @@ export const FlowRunStepUpdatedFrame = z.object({
type: z.literal('flow_run_step_updated'),
run_id: Uuid,
step_id: z.string().min(1),
status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped', 'cancelled']),
status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped', 'cancelled', 'timed_out']),
run_status: z.enum(['running', 'completed', 'failed', 'cancelled']).optional(),
report: z.string().optional(),
});
@@ -405,11 +407,141 @@ export const BattleUpdatedFrame = z.object({
cross_exam_id: Uuid.optional(),
});
// ---- agent snapshot restore frame ------------------------------------------
export const AgentSnapshotFrame = z.object({
type: z.literal('agent_snapshot'),
chat_id: z.string().uuid(),
agent: z.string().nullable().optional(),
model: z.string(),
mode: z.string().nullable().optional(),
turn_number: z.number().int().nonnegative(),
});
// ---- tool trace frames -----------------------------------------------------
export const ToolTraceStartFrame = z.object({
type: z.literal('tool_trace_start'),
trace_id: z.string().uuid(),
message_id: z.string().uuid(),
chat_id: z.string().uuid(),
tool_name: z.string().min(1),
tool_input: z.record(z.unknown()),
started_at: z.string().datetime(),
});
export const ToolTraceFinishFrame = z.object({
type: z.literal('tool_trace_finish'),
trace_id: z.string().uuid(),
message_id: z.string().uuid(),
chat_id: z.string().uuid(),
tool_name: z.string().min(1),
tool_output: z.union([z.string(), z.null()]).optional(),
latency_ms: z.number().int().nonnegative().optional(),
tokens_used: z.number().int().nonnegative().nullable().optional(),
cache_tokens: z.number().int().nonnegative().nullable().optional(),
reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
error: z.string().optional(),
outcome: z.string().optional(),
finished_at: z.string().datetime(),
});
// ---- collision warning frame (v2.8) ----------------------------------------
//
// Published when the BooCoder detects that multiple worktrees/agents are editing
// the same file concurrently. Advisory only — writes are not blocked.
const ConflictSeverityValue = z.enum(['same_line', 'adjacent_line', 'different_area']);
export const CollisionWarningFrame = z.object({
type: z.literal('collision_warning'),
file_path: z.string().min(1),
worktrees: z.array(z.string().min(1)),
agents: z.array(z.string().min(1)),
severity: ConflictSeverityValue,
});
// ---- channel-delta frames (streaming v2) ----------------------------------
//
// Each channel frame carries a monotonic `seq` counter so the client can
// reorder out-of-order deltas per-channel, detect gaps, and request replay on
// reconnect. The `channel` discriminator tells the reducer which substate to
// update.
const TextChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
content: z.string(),
});
const ToolCallChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
tool_call: ToolCallShape,
});
const ToolResultChannelPayload = z.object({
tool_message_id: Uuid,
chat_id: Uuid.optional(),
tool_call_id: ToolCallId,
output: z.unknown(),
truncated: z.boolean(),
error: z.string().optional(),
});
const StatusChannelPayload = z.object({
message_id: Uuid,
chat_id: Uuid.optional(),
status: z.enum(['running', 'complete', 'cancelled', 'failed']).optional(),
tokens_used: z.number().int().nonnegative().nullable().optional(),
ctx_used: z.number().int().nonnegative().nullable().optional(),
ctx_max: z.number().int().positive().nullable().optional(),
cache_tokens: z.number().int().nonnegative().nullable().optional(),
reasoning_tokens: z.number().int().nonnegative().nullable().optional(),
started_at: IsoTimestamp.nullable().optional(),
finished_at: IsoTimestamp.nullable().optional(),
model: z.string().nullable().optional(),
metadata: OpaqueObject.nullable().optional(),
});
const ErrorChannelPayload = z.object({
message_id: Uuid.optional(),
chat_id: Uuid.optional(),
error: z.string(),
reason: ErrorReasonValue.optional(),
});
const ChannelDeltaPayload = z.discriminatedUnion('channel', [
z.object({ channel: z.literal('text'), ...TextChannelPayload.shape }),
z.object({ channel: z.literal('tool_call'), ...ToolCallChannelPayload.shape }),
z.object({ channel: z.literal('tool_result'), ...ToolResultChannelPayload.shape }),
z.object({ channel: z.literal('status'), ...StatusChannelPayload.shape }),
z.object({ channel: z.literal('error'), ...ErrorChannelPayload.shape }),
]);
export const ChannelDeltaFrame = z.object({
type: z.literal('channel_delta'),
seq: z.number().int().nonnegative(),
channel: z.union([
z.literal('text'), z.literal('tool_call'),
z.literal('tool_result'), z.literal('status'), z.literal('error'),
]),
message_id: Uuid.optional(),
chat_id: Uuid.optional(),
content: z.string().optional(),
tool_call: ToolCallShape.optional(),
tool_message_id: Uuid.optional(),
tool_call_id: ToolCallId.optional(),
output: z.unknown().optional(),
truncated: z.boolean().optional(),
});
// ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [
// per-session
SnapshotFrame,
AgentSnapshotFrame,
MessageStartedFrame,
DeltaFrame,
ReasoningDeltaFrame,
@@ -432,6 +564,13 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
BattleStartedFrame,
ContestantUpdatedFrame,
BattleUpdatedFrame,
// tool trace
ToolTraceStartFrame,
ToolTraceFinishFrame,
// collision warning
CollisionWarningFrame,
// channel-delta (streaming v2)
ChannelDeltaFrame,
// per-user
ChatStatusFrame,
SessionUpdatedFrame,
@@ -459,6 +598,7 @@ export type WsFrame = z.infer<typeof WsFrameSchema>;
// by the drift test in src/__tests__/ws-frames.test.ts.
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'snapshot',
'agent_snapshot',
'message_started',
'delta',
'reasoning_delta',
@@ -479,6 +619,10 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'battle_started',
'contestant_updated',
'battle_updated',
'tool_trace_start',
'tool_trace_finish',
'collision_warning',
'channel_delta',
'chat_status',
'session_updated',
'session_renamed',

15
pnpm-lock.yaml generated
View File

@@ -97,6 +97,9 @@ importers:
apps/server:
dependencies:
'@ai-sdk/deepseek':
specifier: ^2.0.35
version: 2.0.35(zod@3.25.76)
'@ai-sdk/openai-compatible':
specifier: ^2.0.47
version: 2.0.47(zod@3.25.76)
@@ -302,6 +305,12 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@ai-sdk/deepseek@2.0.35':
resolution: {integrity: sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.119':
resolution: {integrity: sha512-VAhfRWC+JexZakkVfmjaJKaTj00x7/UHdE8kMWL3NhuQAlf8oXtg9r4dfvFZrByXxchGRBvYE3biEUyibkg0xg==}
engines: {node: '>=18'}
@@ -4363,6 +4372,12 @@ snapshots:
dependencies:
zod: 3.25.76
'@ai-sdk/deepseek@2.0.35(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@3.0.119(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.10