From 9106334e70c0be05cb01889192ccb65ebe084bec Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:11 +0000 Subject: [PATCH 01/11] feat(contracts): add TokenBreakdownSchema and ContestantShape.token_breakdown --- packages/contracts/src/arena.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/contracts/src/arena.ts b/packages/contracts/src/arena.ts index 9855e55..531fc09 100644 --- a/packages/contracts/src/arena.ts +++ b/packages/contracts/src/arena.ts @@ -1,10 +1,23 @@ /** Arena types — single source of truth for cross-app Arena wire contracts. */ +import { z } from 'zod'; + export type BattleType = 'coding' | 'qa'; export type BattleStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; export type ContestantStatus = 'queued' | 'running' | 'done' | 'error'; export type ContestantLane = 'local' | 'cloud'; +export const TokenBreakdownSchema = z.object({ + system: z.number().int().nonnegative(), + user: z.number().int().nonnegative(), + assistant: z.number().int().nonnegative(), + tools: z.number().int().nonnegative(), + reasoning: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), +}); + +export type TokenBreakdown = z.infer; + // Pane state — carried on the WorkspacePane row, mirrors OrchestratorState. export interface ArenaState { battle_id: string; @@ -38,6 +51,7 @@ export interface ContestantShape { duration_ms: number | null; tokens_per_sec: number | null; cost_tokens: number | null; + token_breakdown: TokenBreakdown | null; result_path: string | null; error: string | null; created_at: string; From 373ba86e5d24c41ce9cff836c0cbae0ccf014648 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:15 +0000 Subject: [PATCH 02/11] feat(coder): add edit safety guards against truncation --- apps/coder/src/services/edit-guards.ts | 42 ++++++++++++++++++++++ apps/coder/src/services/pending_changes.ts | 5 +++ 2 files changed, 47 insertions(+) create mode 100644 apps/coder/src/services/edit-guards.ts diff --git a/apps/coder/src/services/edit-guards.ts b/apps/coder/src/services/edit-guards.ts new file mode 100644 index 0000000..94af342 --- /dev/null +++ b/apps/coder/src/services/edit-guards.ts @@ -0,0 +1,42 @@ +// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage, +// and accidental import deletion during native edit_file application. +// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved. + +export interface GuardResult { + ok: boolean; + reason?: string; + charLoss?: number; + lineLoss?: number; +} + +const TRUNCATION_CHAR_THRESHOLD = 0.6; +const TRUNCATION_LINE_THRESHOLD = 0.5; + +export function validateEditResult( + original: string, + updated: string, + filePath: string, +): GuardResult { + // Check for catastrophic content truncation + if (original.length > 0 && updated.length > 0) { + const charLoss = 1 - updated.length / original.length; + const originalLines = original.split('\n').length; + const updatedLines = updated.split('\n').length; + const lineLoss = 1 - updatedLines / originalLines; + + if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) { + return { + ok: false, + reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`, + charLoss, + lineLoss, + }; + } + } + + return { ok: true }; +} + +export function formatGuardError(guard: GuardResult, filePath: string): string { + return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`; +} diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts index cf1d914..dc9280d 100644 --- a/apps/coder/src/services/pending_changes.ts +++ b/apps/coder/src/services/pending_changes.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; import { locateMatch } from './fuzzy-match.js'; +import { validateEditResult, formatGuardError } from './edit-guards.js'; /** * Write a file atomically: stage to a sibling temp file, then rename over the @@ -285,6 +286,10 @@ export async function applyOne( ); } if (plan.kind === 'apply') { + const guard = validateEditResult(toLf(raw), plan.updated, change.file_path); + if (!guard.ok) { + throw new Error(formatGuardError(guard, change.file_path)); + } const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated; await writeFileAtomic(change.file_path, out); } else { From 6b7c2bab1e7ad15eb8ef6a51c4081b8589515f4b Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:19 +0000 Subject: [PATCH 03/11] feat(coder): persist token breakdown in arena decisions and schema --- apps/coder/src/schema.sql | 4 ++++ .../services/__tests__/arena-decisions.test.ts | 18 ++++++++++++++++++ apps/coder/src/services/arena-decisions.ts | 6 ++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 5bef50d..050c9fc 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -423,3 +423,7 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id); -- Cross-examination listing per battle. CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id); + +-- TokenScope: per-category token breakdown on arena contestants and tasks. +ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB; diff --git a/apps/coder/src/services/__tests__/arena-decisions.test.ts b/apps/coder/src/services/__tests__/arena-decisions.test.ts index 176ba22..68ce2f1 100644 --- a/apps/coder/src/services/__tests__/arena-decisions.test.ts +++ b/apps/coder/src/services/__tests__/arena-decisions.test.ts @@ -162,6 +162,24 @@ describe('computeBenchmark', () => { expect(bench.durationMs).toBe(0); expect(bench.tokensPerSec).toBeNull(); }); + + it('includes token breakdown when provided', () => { + const breakdown = { + system: 10, + user: 20, + assistant: 30, + tools: 40, + reasoning: 5, + total: 105, + }; + const bench = computeBenchmark(t0, t1, 500, 'local', breakdown); + expect(bench.tokenBreakdown).toEqual(breakdown); + }); + + it('defaults token breakdown to null when omitted', () => { + const bench = computeBenchmark(t0, t1, 500, 'local'); + expect(bench.tokenBreakdown).toBeNull(); + }); }); // ─── sanitizeSlug ──────────────────────────────────────────────────────────── diff --git a/apps/coder/src/services/arena-decisions.ts b/apps/coder/src/services/arena-decisions.ts index fd2d642..a1d081b 100644 --- a/apps/coder/src/services/arena-decisions.ts +++ b/apps/coder/src/services/arena-decisions.ts @@ -9,7 +9,7 @@ * A contestant's status lifecycle: * queued → running → done | error */ -import type { BattleType, ContestantLane } from '@boocode/contracts/arena'; +import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena'; // ─── Lane classification ────────────────────────────────────────────────────── @@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo export interface Benchmark { durationMs: number; tokensPerSec: number | null; + tokenBreakdown: TokenBreakdown | null; } /** @@ -86,13 +87,14 @@ export function computeBenchmark( endedAt: Date, costTokens: number | null, lane: ContestantLane, + tokenBreakdown: TokenBreakdown | null = null, ): Benchmark { const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime()); const tokensPerSec = lane === 'local' && costTokens !== null && durationMs > 0 ? (costTokens / durationMs) * 1000 : null; - return { durationMs, tokensPerSec }; + return { durationMs, tokensPerSec, tokenBreakdown }; } // ─── Slug / path helpers ────────────────────────────────────────────────────── From 214cc32ac276a3f2fb590fa82ea9787e69dc336a Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:24 +0000 Subject: [PATCH 04/11] feat(codecontext): upgrade sidecar to boocontext MCP aggregator - Multi-stage Dockerfile builds boocontext (Node) + HTTP shim (Go) - shim.go supports CODECONTEXT_CHILD env var for configurable MCP child - Adds routes for get_symbol_details, get_call_graph, get_blast_radius - docker-compose.yml adds env vars for child MCP paths --- codecontext/Dockerfile | 55 ++++++++++++++++++++---------------------- codecontext/shim.go | 19 +++++++++------ docker-compose.yml | 6 +++++ 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/codecontext/Dockerfile b/codecontext/Dockerfile index 529dd82..5f1d049 100644 --- a/codecontext/Dockerfile +++ b/codecontext/Dockerfile @@ -1,41 +1,38 @@ -# v1.12 Track B — codecontext sidecar container. +# 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. # -# Multi-stage build: golang:1.24-alpine builder produces two binaries -# (codecontext from source + our HTTP shim), then a minimal alpine:3.20 -# runtime holds both. +# The shim spawns boocontext as a child MCP process over stdio NDJSON, +# translating HTTP requests to MCP tools/call. # -# No upstream Docker image exists for codecontext. We clone the repo -# directly because the module path declared in go.mod -# (github.com/nuthan-ms/codecontext) differs from the GitHub repo URL -# (github.com/nmakod/codecontext) — `go install` against the GitHub path -# wouldn't resolve. The tagged v3.2.1 source tree is the same either way. +# 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 -FROM golang:1.24-alpine AS builder -WORKDIR /build - -RUN apk add --no-cache git ca-certificates build-base - -# Build codecontext from the boocode-ts fork (has .codecontextignore support). -# Source is staged into the build context by the pre-build step: -# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext . -# CGO is required: codecontext binds tree-sitter via cgo. -COPY fork.tar.gz /build/fork.tar.gz -RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /build/codecontext -WORKDIR /build/codecontext -RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext - -# Build the shim. Stdlib-only — no go.sum needed. +# 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 ./ -# Runtime: alpine matches the build target so codecontext's cgo bindings -# resolve against the same musl libc. +# Stage 2: boocontext MCP builder +FROM node:20-alpine AS boocontext-builder +WORKDIR /build/boocontext +RUN apk add --no-cache git python3 make g++ ca-certificates +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 npm ci && npm run build + +# Stage 3: Runtime FROM alpine:3.20 -RUN apk add --no-cache ca-certificates -COPY --from=builder /build/codecontext-bin /usr/local/bin/codecontext -COPY --from=builder /build/shim-bin /usr/local/bin/shim +RUN apk add --no-cache ca-certificates nodejs uv +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 \ diff --git a/codecontext/shim.go b/codecontext/shim.go index 5e4b3c5..3a38f28 100644 --- a/codecontext/shim.go +++ b/codecontext/shim.go @@ -26,6 +26,7 @@ import ( "os" "os/exec" "os/signal" + "strings" "sync" "sync/atomic" "syscall" @@ -185,13 +186,14 @@ func notify(method string, params any) error { // ---- Child lifecycle ---- func startChild() error { - // `codecontext mcp` with --watch=true (the default) keeps fsnotify - // running on the indexed directory; the per-call target_dir swap - // invalidates and re-indexes on demand. `--target=/opt/projects` is the - // initial scan target — codecontext rebuilds the graph against whatever - // target_dir each call carries, so this is just a valid bootstrap path - // (the default "." is the alpine root and trips on transient /proc fds). - child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore") + // Support CODECONTEXT_CHILD env var for overriding the MCP child command. + // Default to boocontext (Node.js MCP aggregator). Set in docker-compose. + childCmd := os.Getenv("CODECONTEXT_CHILD") + if childCmd == "" { + childCmd = "node /usr/local/lib/boocontext/dist/index.js" + } + parts := strings.Split(childCmd, " ") + child = exec.Command(parts[0], parts[1:]...) var err error childStdin, err = child.StdinPipe() if err != nil { @@ -417,6 +419,9 @@ func main() { mux.HandleFunc("POST /v1/watch_changes", makeToolHandler("watch_changes")) mux.HandleFunc("POST /v1/get_semantic_neighborhoods", makeToolHandler("get_semantic_neighborhoods")) mux.HandleFunc("POST /v1/get_framework_analysis", makeToolHandler("get_framework_analysis")) + mux.HandleFunc("POST /v1/get_symbol_details", makeToolHandler("get_symbol_details")) + mux.HandleFunc("POST /v1/get_call_graph", makeToolHandler("get_call_graph")) + mux.HandleFunc("POST /v1/get_blast_radius", makeToolHandler("get_blast_radius")) server := &http.Server{ Addr: ":8080", diff --git a/docker-compose.yml b/docker-compose.yml index 8adbb3b..1333767 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -109,10 +109,16 @@ services: ports: - "127.0.0.1:8080:8080" restart: unless-stopped + environment: + CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js + 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 From bc83475a3debd136eea9810cd4729ecfabdf00ca Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:29 +0000 Subject: [PATCH 05/11] feat(server): add boocontext deep analysis tools and synthesis pipeline - get_symbol_details: type signature, definition location, usage count - get_call_graph: callers, callees, transitive references - get_blast_radius added to SYNTHESIS_TOOLS --- apps/server/src/services/synthesisPipeline.ts | 1 + .../tools/codecontext/get_call_graph.ts | 31 +++++++++++++++++++ .../tools/codecontext/get_symbol_details.ts | 31 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 apps/server/src/services/tools/codecontext/get_call_graph.ts create mode 100644 apps/server/src/services/tools/codecontext/get_symbol_details.ts diff --git a/apps/server/src/services/synthesisPipeline.ts b/apps/server/src/services/synthesisPipeline.ts index 7b31da0..1646a89 100644 --- a/apps/server/src/services/synthesisPipeline.ts +++ b/apps/server/src/services/synthesisPipeline.ts @@ -35,6 +35,7 @@ export const SYNTHESIS_TOOLS: ReadonlySet = new Set([ 'get_codebase_overview', 'get_framework_analysis', 'get_semantic_neighborhoods', + 'get_blast_radius', ]); const TOP_N_FILES = 5; diff --git a/apps/server/src/services/tools/codecontext/get_call_graph.ts b/apps/server/src/services/tools/codecontext/get_call_graph.ts new file mode 100644 index 0000000..c6b5b5e --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_call_graph.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { makeCodecontextTool } from './factory.js'; + +export const GetCallGraphInput = z.object({ + symbol: z.string().describe('Symbol name to analyze'), + depth: z.number().int().min(1).max(5).optional().describe('Max traversal depth (default 2)'), +}); +export type GetCallGraphInputT = z.infer; + +const DESCRIPTION = + 'Returns a call graph for a function or method: callers, callees, and transitive references. ' + + 'Use to understand how a symbol is invoked and what it depends on.'; + +const { toolDef: getCallGraph, execute: executeGetCallGraph } = + makeCodecontextTool({ + name: 'get_call_graph', + schema: GetCallGraphInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Symbol name to analyze' }, + depth: { type: 'number', description: 'Max traversal depth (default 2)' }, + }, + required: ['symbol'], + additionalProperties: false, + }, + mapArgs: (input) => ({ symbol: input.symbol, depth: input.depth ?? 2 }), + }); + +export { getCallGraph, executeGetCallGraph }; diff --git a/apps/server/src/services/tools/codecontext/get_symbol_details.ts b/apps/server/src/services/tools/codecontext/get_symbol_details.ts new file mode 100644 index 0000000..d2bec3d --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_symbol_details.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { makeCodecontextTool } from './factory.js'; + +export const GetSymbolDetailsInput = z.object({ + symbol: z.string().describe('Symbol name to resolve'), + file_path: z.string().optional().describe('Optional file path to narrow search'), +}); +export type GetSymbolDetailsInputT = z.infer; + +const DESCRIPTION = + 'Returns type signature, definition location, and usage count for a named symbol. ' + + 'Use after get_codebase_overview to dive deeper into specific functions, classes, or variables.'; + +const { toolDef: getSymbolDetails, execute: executeGetSymbolDetails } = + makeCodecontextTool({ + name: 'get_symbol_details', + schema: GetSymbolDetailsInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Symbol name to resolve' }, + file_path: { type: 'string', description: 'Optional file path to narrow search' }, + }, + required: ['symbol'], + additionalProperties: false, + }, + mapArgs: (input) => ({ symbol: input.symbol, file_path: input.file_path }), + }); + +export { getSymbolDetails, executeGetSymbolDetails }; From ee749d8698bfd72e64eef1e896200ead970088f8 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:35 +0000 Subject: [PATCH 06/11] feat(coder): add LSP code intelligence tools - lsp/ module: types, config, JSON-RPC client, server-manager, operations - lsp_diagnostics: TypeScript/JavaScript diagnostics for a file - lsp_goto_definition: find symbol definition at position - lsp_find_references: find all references to a symbol - Registered as READ_TOOLS in tool index --- apps/coder/src/services/lsp/client.ts | 75 +++++++++++ apps/coder/src/services/lsp/config.ts | 19 +++ apps/coder/src/services/lsp/operations.ts | 86 +++++++++++++ apps/coder/src/services/lsp/server-manager.ts | 119 ++++++++++++++++++ apps/coder/src/services/lsp/types.ts | 28 +++++ apps/coder/src/services/tools/index.ts | 17 ++- .../src/services/tools/lsp_diagnostics.ts | 48 +++++++ .../src/services/tools/lsp_find_references.ts | 49 ++++++++ .../src/services/tools/lsp_goto_definition.ts | 48 +++++++ 9 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 apps/coder/src/services/lsp/client.ts create mode 100644 apps/coder/src/services/lsp/config.ts create mode 100644 apps/coder/src/services/lsp/operations.ts create mode 100644 apps/coder/src/services/lsp/server-manager.ts create mode 100644 apps/coder/src/services/lsp/types.ts create mode 100644 apps/coder/src/services/tools/lsp_diagnostics.ts create mode 100644 apps/coder/src/services/tools/lsp_find_references.ts create mode 100644 apps/coder/src/services/tools/lsp_goto_definition.ts diff --git a/apps/coder/src/services/lsp/client.ts b/apps/coder/src/services/lsp/client.ts new file mode 100644 index 0000000..6b26c66 --- /dev/null +++ b/apps/coder/src/services/lsp/client.ts @@ -0,0 +1,75 @@ +import { createInterface } from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; + +interface RpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params?: unknown; +} + +interface RpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +export class LspClient { + private nextId = 1; + private pending = new Map void; reject: (e: Error) => void }>(); + private buffer = ''; + + constructor( + private stdin: Writable, + private stdout: Readable, + ) { + const rl = createInterface({ input: stdout, crlfDelay: Infinity }); + rl.on('line', (line) => this.handleLine(line)); + } + + private handleLine(line: string): void { + this.buffer += line + '\n'; + const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/); + if (!match || !match[1]) return; + const len = parseInt(match[1], 10); + const headerEnd = match.index! + match[0].length; + const body = this.buffer.slice(headerEnd, headerEnd + len); + if (body.length < len) return; + this.buffer = this.buffer.slice(headerEnd + len); + try { + const msg: RpcResponse = JSON.parse(body); + const cb = this.pending.get(msg.id); + if (cb) { + this.pending.delete(msg.id); + cb.resolve(msg); + } + } catch { + // Malformed JSON, ignore + } + } + + async request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const req: RpcRequest = { jsonrpc: '2.0', id, method, params }; + const body = JSON.stringify(req); + const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`; + + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (resp) => { + if (resp.error) reject(new Error(resp.error.message)); + else resolve(resp.result); + }, + reject, + }); + this.stdin.write(header + body); + }); + } + + async notify(method: string, params?: unknown): Promise { + const body = JSON.stringify({ jsonrpc: '2.0', method, params }); + const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`; + this.stdin.write(header + body); + } +} diff --git a/apps/coder/src/services/lsp/config.ts b/apps/coder/src/services/lsp/config.ts new file mode 100644 index 0000000..3c97fa4 --- /dev/null +++ b/apps/coder/src/services/lsp/config.ts @@ -0,0 +1,19 @@ +export interface LspServerConfig { + command: string; + args: string[]; + rootPatterns: string[]; +} + +const TS_CONFIG: LspServerConfig = { + command: 'typescript-language-server', + args: ['--stdio'], + rootPatterns: ['package.json', 'tsconfig.json'], +}; + +const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']); + +export function getServerConfig(filePath: string): LspServerConfig | null { + const ext = filePath.split('.').pop()?.toLowerCase(); + if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG; + return null; +} diff --git a/apps/coder/src/services/lsp/operations.ts b/apps/coder/src/services/lsp/operations.ts new file mode 100644 index 0000000..b1f1006 --- /dev/null +++ b/apps/coder/src/services/lsp/operations.ts @@ -0,0 +1,86 @@ +import type { LspClient } from './client.js'; +import type { Diagnostic, Location } from './types.js'; + +function fileUri(filePath: string): string { + return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`; +} + +export async function openDocument( + client: LspClient, + filePath: string, + content: string, + version: number = 1, +): Promise { + const uri = fileUri(filePath); + await client.notify('textDocument/didOpen', { + textDocument: { uri, languageId: 'typescript', version, text: content }, + }); +} + +export async function closeDocument(client: LspClient, filePath: string): Promise { + await client.notify('textDocument/didClose', { + textDocument: { uri: fileUri(filePath) }, + }); +} + +export async function getDiagnostics( + client: LspClient, + filePath: string, + content: string, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/diagnostic', { + textDocument: { uri }, + }); + await closeDocument(client, filePath); + const diagnostics: Diagnostic[] = []; + if (result?.diagnostics) { + for (const d of result.diagnostics) { + diagnostics.push({ + range: d.range, + severity: d.severity ?? 1, + message: d.message, + source: d.source, + }); + } + } + return diagnostics; +} + +export async function gotoDefinition( + client: LspClient, + filePath: string, + content: string, + line: number, + character: number, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/definition', { + textDocument: { uri }, + position: { line, character }, + }); + await closeDocument(client, filePath); + if (!result) return null; + const loc = Array.isArray(result) ? result[0] : result; + return loc ? { uri: loc.uri, range: loc.range } : null; +} + +export async function findReferences( + client: LspClient, + filePath: string, + content: string, + line: number, + character: number, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/references', { + textDocument: { uri }, + position: { line, character }, + context: { includeDeclaration: true }, + }); + await closeDocument(client, filePath); + return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range })); +} diff --git a/apps/coder/src/services/lsp/server-manager.ts b/apps/coder/src/services/lsp/server-manager.ts new file mode 100644 index 0000000..a3cd60b --- /dev/null +++ b/apps/coder/src/services/lsp/server-manager.ts @@ -0,0 +1,119 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { LspClient } from './client.js'; +import { getServerConfig } from './config.js'; + +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const SWEEP_INTERVAL_MS = 30_000; + +interface LspInstance { + client: LspClient; + proc: ChildProcess; + lastUsed: number; + timer: ReturnType; +} + +export class LspServerManager { + private instances = new Map(); + private sweepTimer: ReturnType | null = null; + + constructor() { + this.startSweeper(); + } + + private startSweeper(): void { + this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + this.sweepTimer.unref?.(); + } + + private findProjectRoot(filePath: string): string | null { + let dir = filePath; + const config = getServerConfig(filePath); + if (!config) return null; + while (true) { + for (const pattern of config.rootPatterns) { + if (existsSync(join(dir, pattern))) return dir; + } + const parent = join(dir, '..'); + if (parent === dir) return dir; + dir = parent; + } + } + + async getClient(filePath: string): Promise { + const config = getServerConfig(filePath); + if (!config) return null; + const projectRoot = this.findProjectRoot(filePath); + if (!projectRoot) return null; + + const existing = this.instances.get(projectRoot); + if (existing) { + existing.lastUsed = Date.now(); + clearTimeout(existing.timer); + existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS); + existing.timer.unref?.(); + return existing.client; + } + + return this.spawn(projectRoot, config.command, config.args); + } + + private async spawn(projectRoot: string, command: string, args: string[]): Promise { + const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot }); + const client = new LspClient(proc.stdin!, proc.stdout!); + + await client.request('initialize', { + processId: process.pid, + rootUri: `file://${projectRoot}`, + capabilities: { + textDocument: { + diagnostic: { dynamicRegistration: false }, + definition: { dynamicRegistration: false }, + references: { dynamicRegistration: false }, + }, + }, + }); + await client.notify('initialized', {}); + + const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS); + timer.unref?.(); + + this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer }); + proc.on('exit', () => this.instances.delete(projectRoot)); + + return client; + } + + private kill(projectRoot: string): void { + const inst = this.instances.get(projectRoot); + if (!inst) return; + this.instances.delete(projectRoot); + inst.proc.kill('SIGTERM'); + setTimeout(() => { + if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL'); + }, 5000); + } + + private sweep(): void { + const now = Date.now(); + for (const [root, inst] of this.instances) { + if (now - inst.lastUsed > IDLE_TIMEOUT_MS) { + this.kill(root); + } + } + } + + shutdown(): void { + if (this.sweepTimer) clearInterval(this.sweepTimer); + for (const root of [...this.instances.keys()]) { + this.kill(root); + } + } + + getActiveCount(): number { + return this.instances.size; + } +} + +export const lspManager = new LspServerManager(); diff --git a/apps/coder/src/services/lsp/types.ts b/apps/coder/src/services/lsp/types.ts new file mode 100644 index 0000000..20808f9 --- /dev/null +++ b/apps/coder/src/services/lsp/types.ts @@ -0,0 +1,28 @@ +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface Diagnostic { + range: Range; + severity: number; + message: string; + source?: string; +} + +export interface TextDocumentItem { + uri: string; + languageId: string; + version: number; + text: string; +} diff --git a/apps/coder/src/services/tools/index.ts b/apps/coder/src/services/tools/index.ts index c4d3d06..7961d9e 100644 --- a/apps/coder/src/services/tools/index.ts +++ b/apps/coder/src/services/tools/index.ts @@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js'; import { newTaskTool } from './new_task.js'; import { listTasksTool } from './list_tasks.js'; import { checkTaskStatusTool } from './check_task_status.js'; +import { lspDiagnosticsTool } from './lsp_diagnostics.js'; +import { lspGotoDefinitionTool } from './lsp_goto_definition.js'; +import { lspFindReferencesTool } from './lsp_find_references.js'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js'; @@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef[] = [ checkTaskStatusTool, ]; -export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; +// Read-only agent tools for code intelligence. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const READ_TOOLS: readonly ToolDef[] = [ + lspDiagnosticsTool, + lspGotoDefinitionTool, + lspFindReferencesTool, +]; + +export { + editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, + newTaskTool, listTasksTool, checkTaskStatusTool, + lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool, +}; diff --git a/apps/coder/src/services/tools/lsp_diagnostics.ts b/apps/coder/src/services/tools/lsp_diagnostics.ts new file mode 100644 index 0000000..44ee2ec --- /dev/null +++ b/apps/coder/src/services/tools/lsp_diagnostics.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { getDiagnostics } from '../lsp/operations.js'; + +const LspDiagnosticsInput = z.object({ + file_path: z.string().describe('Path to the file to check for diagnostics'), +}); + +type InputT = z.infer; + +export const lspDiagnosticsTool: ToolDef = { + name: 'lsp_diagnostics', + description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.', + inputSchema: LspDiagnosticsInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_diagnostics', + description: 'Get TypeScript/JavaScript diagnostics for a file', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file' }, + }, + required: ['file_path'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type for LSP diagnostics' }; + + const diagnostics = await getDiagnostics(client, resolved, content); + if (diagnostics.length === 0) return { result: 'No diagnostics found.' }; + + const lines = diagnostics.map((d) => { + const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown'; + return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`; + }); + return { result: lines.join('\n') }; + }, +}; diff --git a/apps/coder/src/services/tools/lsp_find_references.ts b/apps/coder/src/services/tools/lsp_find_references.ts new file mode 100644 index 0000000..0f8418b --- /dev/null +++ b/apps/coder/src/services/tools/lsp_find_references.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { findReferences } from '../lsp/operations.js'; + +const LspFindReferencesInput = z.object({ + file_path: z.string().describe('Path to the source file'), + line: z.number().int().nonnegative().describe('0-based line number'), + character: z.number().int().nonnegative().describe('0-based character offset'), +}); + +type InputT = z.infer; + +export const lspFindReferencesTool: ToolDef = { + name: 'lsp_find_references', + description: 'Find all references to a symbol at a given position in a file.', + inputSchema: LspFindReferencesInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_find_references', + description: 'Find all references to symbol at position', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string' }, + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['file_path', 'line', 'character'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type' }; + + const refs = await findReferences(client, resolved, content, input.line, input.character); + if (refs.length === 0) return { result: 'No references found.' }; + + const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`); + return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` }; + }, +}; diff --git a/apps/coder/src/services/tools/lsp_goto_definition.ts b/apps/coder/src/services/tools/lsp_goto_definition.ts new file mode 100644 index 0000000..d1c9ace --- /dev/null +++ b/apps/coder/src/services/tools/lsp_goto_definition.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { gotoDefinition } from '../lsp/operations.js'; + +const LspGotoDefinitionInput = z.object({ + file_path: z.string().describe('Path to the source file'), + line: z.number().int().nonnegative().describe('0-based line number'), + character: z.number().int().nonnegative().describe('0-based character offset'), +}); + +type InputT = z.infer; + +export const lspGotoDefinitionTool: ToolDef = { + name: 'lsp_goto_definition', + description: 'Find the definition of a symbol at a given position in a file.', + inputSchema: LspGotoDefinitionInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_goto_definition', + description: 'Find definition of symbol at position', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string' }, + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['file_path', 'line', 'character'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type' }; + + const loc = await gotoDefinition(client, resolved, content, input.line, input.character); + if (!loc) return { result: 'No definition found.' }; + + return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` }; + }, +}; From b8b2666fdc59af17f17fb3f7026be41e0b84da42 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:39 +0000 Subject: [PATCH 07/11] feat(server): add DCP clean-room context pruning - Deduplication: removes consecutive identical tool_call+tool_result pairs - Purge-errors: removes failed/empty tool results - Transform orchestrator runs strategies in sequence pre-payload - Wired into turn.ts before buildMessagesPayload - Clean-room reimplementation (AGPL reference: behavior only) --- .../dcp/__tests__/deduplication.test.ts | 33 ++++++++++++ .../inference/dcp/__tests__/messages.test.ts | 22 ++++++++ .../dcp/__tests__/purge-errors.test.ts | 33 ++++++++++++ .../inference/dcp/__tests__/transform.test.ts | 25 +++++++++ .../src/services/inference/dcp/index.ts | 4 ++ .../src/services/inference/dcp/messages.ts | 34 ++++++++++++ .../src/services/inference/dcp/state.ts | 27 ++++++++++ .../inference/dcp/strategies/deduplication.ts | 50 ++++++++++++++++++ .../inference/dcp/strategies/purge-errors.ts | 34 ++++++++++++ .../src/services/inference/dcp/transform.ts | 52 +++++++++++++++++++ apps/server/src/services/inference/turn.ts | 14 ++++- 11 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/services/inference/dcp/__tests__/deduplication.test.ts create mode 100644 apps/server/src/services/inference/dcp/__tests__/messages.test.ts create mode 100644 apps/server/src/services/inference/dcp/__tests__/purge-errors.test.ts create mode 100644 apps/server/src/services/inference/dcp/__tests__/transform.test.ts create mode 100644 apps/server/src/services/inference/dcp/index.ts create mode 100644 apps/server/src/services/inference/dcp/messages.ts create mode 100644 apps/server/src/services/inference/dcp/state.ts create mode 100644 apps/server/src/services/inference/dcp/strategies/deduplication.ts create mode 100644 apps/server/src/services/inference/dcp/strategies/purge-errors.ts create mode 100644 apps/server/src/services/inference/dcp/transform.ts diff --git a/apps/server/src/services/inference/dcp/__tests__/deduplication.test.ts b/apps/server/src/services/inference/dcp/__tests__/deduplication.test.ts new file mode 100644 index 0000000..1b7eb89 --- /dev/null +++ b/apps/server/src/services/inference/dcp/__tests__/deduplication.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { deduplicate } from '../strategies/deduplication.js'; +import type { DcpMessage } from '../messages.js'; + +describe('deduplicate', () => { + it('removes consecutive identical tool_call+tool_result pairs', () => { + const messages: DcpMessage[] = [ + { role: 'user', content: 'search for x' }, + { role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result1', tool_call_id: '1' }, + // Duplicate pair + { role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result1', tool_call_id: '2' }, + ]; + + const { messages: result, stats } = deduplicate(messages); + expect(result).toHaveLength(3); // user + first pair + expect(stats.removedCount).toBe(2); + }); + + it('preserves non-duplicate content', () => { + const messages: DcpMessage[] = [ + { role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result1', tool_call_id: '1' }, + { role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result + ]; + + const { messages: result, stats } = deduplicate(messages); + expect(result).toHaveLength(4); + expect(stats.removedCount).toBe(0); + }); +}); diff --git a/apps/server/src/services/inference/dcp/__tests__/messages.test.ts b/apps/server/src/services/inference/dcp/__tests__/messages.test.ts new file mode 100644 index 0000000..2228a64 --- /dev/null +++ b/apps/server/src/services/inference/dcp/__tests__/messages.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { toDcpMessages, fromDcpMessages } from '../messages.js'; + +describe('toDcpMessages', () => { + it('converts user messages', () => { + const result = toDcpMessages([{ role: 'user', content: 'hello' }]); + expect(result[0].role).toBe('user'); + expect(result[0].content).toBe('hello'); + }); + + it('marks Error: content as isError', () => { + const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]); + expect(result[0].isError).toBe(true); + }); +}); + +describe('fromDcpMessages', () => { + it('round-trips messages', () => { + const original = [{ role: 'user', content: 'hello' }]; + expect(fromDcpMessages(toDcpMessages(original))).toEqual(original); + }); +}); diff --git a/apps/server/src/services/inference/dcp/__tests__/purge-errors.test.ts b/apps/server/src/services/inference/dcp/__tests__/purge-errors.test.ts new file mode 100644 index 0000000..153c76a --- /dev/null +++ b/apps/server/src/services/inference/dcp/__tests__/purge-errors.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { purgeErrors } from '../strategies/purge-errors.js'; +import type { DcpMessage } from '../messages.js'; + +describe('purgeErrors', () => { + it('removes tool results where content starts with Error:', () => { + const messages: DcpMessage[] = [ + { role: 'tool', content: 'Error: file not found', tool_call_id: '1' }, + { role: 'tool', content: '{"files":[]}', tool_call_id: '2' }, + ]; + const { messages: result, stats } = purgeErrors(messages); + expect(result).toHaveLength(1); + expect(stats.removedCount).toBe(1); + }); + + it('removes empty tool results', () => { + const messages: DcpMessage[] = [ + { role: 'tool', content: '', tool_call_id: '1' }, + ]; + const { messages: result, stats } = purgeErrors(messages); + expect(result).toHaveLength(0); + expect(stats.removedCount).toBe(1); + }); + + it('preserves valid tool results', () => { + const messages: DcpMessage[] = [ + { role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' }, + ]; + const { messages: result, stats } = purgeErrors(messages); + expect(result).toHaveLength(1); + expect(stats.removedCount).toBe(0); + }); +}); diff --git a/apps/server/src/services/inference/dcp/__tests__/transform.test.ts b/apps/server/src/services/inference/dcp/__tests__/transform.test.ts new file mode 100644 index 0000000..1d6b0e6 --- /dev/null +++ b/apps/server/src/services/inference/dcp/__tests__/transform.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { transformMessages } from '../transform.js'; +import type { DcpMessage } from '../messages.js'; + +describe('transformMessages', () => { + it('applies dedup then purge in order', () => { + const input: DcpMessage[] = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result', tool_call_id: '1' }, + { role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] }, + { role: 'tool', content: 'result', tool_call_id: '2' }, // Dup + ]; + + const { messages, stats } = transformMessages('test-chat', input); + expect(stats.removedCount).toBeGreaterThan(0); + expect(messages.length).toBeLessThan(input.length); + }); + + it('handles empty input', () => { + const { messages, stats } = transformMessages('empty', []); + expect(messages).toHaveLength(0); + expect(stats.removedCount).toBe(0); + }); +}); diff --git a/apps/server/src/services/inference/dcp/index.ts b/apps/server/src/services/inference/dcp/index.ts new file mode 100644 index 0000000..7bc55a5 --- /dev/null +++ b/apps/server/src/services/inference/dcp/index.ts @@ -0,0 +1,4 @@ +export { transformMessages } from './transform.js'; +export type { DcpMessage } from './messages.js'; +export { toDcpMessages, fromDcpMessages } from './messages.js'; +export { getDcpState, clearDcpState } from './state.js'; diff --git a/apps/server/src/services/inference/dcp/messages.ts b/apps/server/src/services/inference/dcp/messages.ts new file mode 100644 index 0000000..b75389b --- /dev/null +++ b/apps/server/src/services/inference/dcp/messages.ts @@ -0,0 +1,34 @@ +// DCP message shape adapter. +// Converts between BooCode MessagePart[] and the DCP internal shape. +// Clean-room implementation — no AGPL source copied. + +export interface DcpMessage { + role: 'user' | 'assistant' | 'tool'; + content: string; + tool_call_id?: string; + tool_calls?: Array<{ id: string; name: string; arguments: string }>; + isError?: boolean; +} + +export function toDcpMessages(parts: any[]): DcpMessage[] { + return parts.map((p: any) => { + const msg: DcpMessage = { role: p.role, content: p.content ?? '' }; + if (p.tool_call_id) msg.tool_call_id = p.tool_call_id; + if (p.tool_calls) msg.tool_calls = p.tool_calls; + if (p.isError) msg.isError = true; + if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) { + msg.isError = true; + } + return msg; + }); +} + +export function fromDcpMessages(msgs: DcpMessage[]): any[] { + return msgs.map((m) => ({ + role: m.role, + content: m.content, + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), + ...(m.isError ? { isError: true } : {}), + })); +} diff --git a/apps/server/src/services/inference/dcp/state.ts b/apps/server/src/services/inference/dcp/state.ts new file mode 100644 index 0000000..487b831 --- /dev/null +++ b/apps/server/src/services/inference/dcp/state.ts @@ -0,0 +1,27 @@ +// Per-chat session state for DCP. +// Tracks last transform timestamp and message count to avoid re-processing. + +interface ChatDcpState { + lastTransformAt: number; + lastMessageCount: number; +} + +const chatStates = new Map(); + +export function getDcpState(chatId: string): ChatDcpState | undefined { + return chatStates.get(chatId); +} + +export function setDcpState(chatId: string, messageCount: number): void { + chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount }); +} + +export function clearDcpState(chatId: string): void { + chatStates.delete(chatId); +} + +export function shouldTransform(chatId: string, messageCount: number): boolean { + const state = chatStates.get(chatId); + if (!state) return true; + return state.lastMessageCount !== messageCount; +} diff --git a/apps/server/src/services/inference/dcp/strategies/deduplication.ts b/apps/server/src/services/inference/dcp/strategies/deduplication.ts new file mode 100644 index 0000000..8c928bc --- /dev/null +++ b/apps/server/src/services/inference/dcp/strategies/deduplication.ts @@ -0,0 +1,50 @@ +import type { DcpMessage } from '../messages.js'; + +export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } { + const result: DcpMessage[] = []; + let removedCount = 0; + let freedTokens = 0; + let i = 0; + + while (i < messages.length) { + const current: DcpMessage = messages[i]!; + const next = messages[i + 1]; + + if ( + current.role === 'assistant' && + current.tool_calls && + next && + next.role === 'tool' && + next.tool_call_id === current.tool_calls[0]?.id + ) { + const nextNext = messages[i + 2]; + const nextNextNext = messages[i + 3]; + + if ( + nextNext && + nextNext.role === 'assistant' && + nextNext.tool_calls && + nextNextNext && + nextNextNext.role === 'tool' && + nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id && + nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name && + nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments && + nextNextNext.content === next.content + ) { + result.push(current, next); + i += 4; + removedCount += 2; + freedTokens += Math.ceil(nextNext.content.length / 4); + freedTokens += Math.ceil(current.content.length / 4); + } else { + result.push(current); + i++; + } + } else { + result.push(current); + i++; + } + } + + return { messages: result, stats: { removedCount, freedTokens } }; +} diff --git a/apps/server/src/services/inference/dcp/strategies/purge-errors.ts b/apps/server/src/services/inference/dcp/strategies/purge-errors.ts new file mode 100644 index 0000000..195442a --- /dev/null +++ b/apps/server/src/services/inference/dcp/strategies/purge-errors.ts @@ -0,0 +1,34 @@ +// Purge-errors strategy — removes failed/empty tool_result entries. +// Clean-room implementation. + +import type { DcpMessage } from '../messages.js'; + +const ERROR_PREFIXES = ['Error:', 'error:', 'Error: ']; +const DEFAULT_WINDOW = 5; + +export function purgeErrors( + messages: DcpMessage[], + windowSize: number = DEFAULT_WINDOW, +): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } { + const result: DcpMessage[] = []; + let removedCount = 0; + let freedTokens = 0; + + for (const msg of messages) { + if (msg.role === 'tool') { + const shouldRemove = + msg.isError || + ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) || + msg.content.trim() === ''; + + if (shouldRemove) { + removedCount++; + freedTokens += Math.ceil(msg.content.length / 4); + continue; // Skip this message + } + } + result.push(msg); + } + + return { messages: result, stats: { removedCount, freedTokens } }; +} diff --git a/apps/server/src/services/inference/dcp/transform.ts b/apps/server/src/services/inference/dcp/transform.ts new file mode 100644 index 0000000..6de0950 --- /dev/null +++ b/apps/server/src/services/inference/dcp/transform.ts @@ -0,0 +1,52 @@ +// Transform orchestrator — runs DCP strategies in sequence. +// Clean-room implementation. + +import type { DcpMessage } from './messages.js'; +import { deduplicate } from './strategies/deduplication.js'; +import { purgeErrors } from './strategies/purge-errors.js'; +import { getDcpState, setDcpState, shouldTransform } from './state.js'; + +export interface TransformStats { + removedCount: number; + freedTokens: number; + dedupRemoved: number; + purgeRemoved: number; +} + +export interface TransformResult { + messages: DcpMessage[]; + stats: TransformStats; +} + +export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult { + if (!shouldTransform(chatId, messages.length)) { + return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } }; + } + + let m = messages; + + // Step 1: Deduplicate + const dedupResult = deduplicate(m); + m = dedupResult.messages; + const dedupRemoved = dedupResult.stats.removedCount; + + // Step 2: Purge errors + const purgeResult = purgeErrors(m); + m = purgeResult.messages; + const purgeRemoved = purgeResult.stats.removedCount; + + const totalRemoved = dedupRemoved + purgeRemoved; + const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens; + + setDcpState(chatId, messages.length); + + return { + messages: m, + stats: { + removedCount: totalRemoved, + freedTokens: totalFreed, + dedupRemoved, + purgeRemoved, + }, + }; +} diff --git a/apps/server/src/services/inference/turn.ts b/apps/server/src/services/inference/turn.ts index e6582c4..12c4ff3 100644 --- a/apps/server/src/services/inference/turn.ts +++ b/apps/server/src/services/inference/turn.ts @@ -21,6 +21,7 @@ import { buildMessagesPayload, loadContext, } from './payload.js'; +import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js'; import { finalizeCompletion, finalizeEmpty, @@ -156,9 +157,20 @@ export async function runAssistantTurn( ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop'); break; } - const { session: iterSession, project: iterProject, history } = loaded; + let { session: iterSession, project: iterProject, history } = loaded; const projectRoot = await resolveProjectRoot(iterProject.path); + try { + const dcpMsgs = toDcpMessages(history); + const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs); + if (stats.removedCount > 0) { + ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages'); + history = fromDcpMessages(pruned) as typeof history; + } + } catch (err) { + ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped'); + } + // v1.14.0: log step boundary for instrumentation. step_start parts are in // the schema CHECK but not emitted here — writing to the assistant message // before the stream phase creates a sequence-0 collision with From 02bb355a092c44c3c89f6fe5fb43bf44da8c7c19 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:44 +0000 Subject: [PATCH 08/11] feat(server): add institutional memory recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - File-based memory under .boocode/memory/ (project/user/reference topics) - Hierarchical 4-scope scan: global → home → project → session - Keyword/tag relevance matching for query-based recall - Injected as block in system prompt at assembly - v1 recall-only (extract/dream deferred to v2) --- .../services/memory/__tests__/entries.test.ts | 31 ++++++++ .../services/memory/__tests__/paths.test.ts | 14 ++++ .../services/memory/__tests__/prompt.test.ts | 15 ++++ .../services/memory/__tests__/recall.test.ts | 15 ++++ apps/server/src/services/memory/entries.ts | 54 ++++++++++++++ apps/server/src/services/memory/index.ts | 6 ++ apps/server/src/services/memory/paths.ts | 17 +++++ apps/server/src/services/memory/prompt.ts | 5 ++ apps/server/src/services/memory/recall.ts | 44 ++++++++++++ apps/server/src/services/memory/scan.ts | 72 +++++++++++++++++++ apps/server/src/services/memory/store.ts | 35 +++++++++ apps/server/src/services/system-prompt.ts | 8 ++- 12 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/services/memory/__tests__/entries.test.ts create mode 100644 apps/server/src/services/memory/__tests__/paths.test.ts create mode 100644 apps/server/src/services/memory/__tests__/prompt.test.ts create mode 100644 apps/server/src/services/memory/__tests__/recall.test.ts create mode 100644 apps/server/src/services/memory/entries.ts create mode 100644 apps/server/src/services/memory/index.ts create mode 100644 apps/server/src/services/memory/paths.ts create mode 100644 apps/server/src/services/memory/prompt.ts create mode 100644 apps/server/src/services/memory/recall.ts create mode 100644 apps/server/src/services/memory/scan.ts create mode 100644 apps/server/src/services/memory/store.ts diff --git a/apps/server/src/services/memory/__tests__/entries.test.ts b/apps/server/src/services/memory/__tests__/entries.test.ts new file mode 100644 index 0000000..a72588b --- /dev/null +++ b/apps/server/src/services/memory/__tests__/entries.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { parseMemoryEntries } from '../entries.js'; + +describe('parseMemoryEntries', () => { + it('parses a single entry with tags', () => { + const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n'; + const entries = parseMemoryEntries('style.md', md); + expect(entries).toHaveLength(1); + expect(entries[0].title).toBe('Indentation'); + expect(entries[0].topic).toBe('project'); + expect(entries[0].tags).toEqual(['style']); + expect(entries[0].content).toContain('two-space'); + }); + + it('parses multiple entries', () => { + const md = [ + '## project: Style', + '', + 'Use tab indentation', + '', + '## user: Preference', + '', + 'Prefer pnpm', + '', + ].join('\n'); + const entries = parseMemoryEntries('mem.md', md); + expect(entries).toHaveLength(2); + expect(entries[0].topic).toBe('project'); + expect(entries[1].topic).toBe('user'); + }); +}); diff --git a/apps/server/src/services/memory/__tests__/paths.test.ts b/apps/server/src/services/memory/__tests__/paths.test.ts new file mode 100644 index 0000000..45fc5c7 --- /dev/null +++ b/apps/server/src/services/memory/__tests__/paths.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { getMemoryRoot, getTopicDir } from '../paths.js'; + +describe('getMemoryRoot', () => { + it('returns .boocode/memory under project root', () => { + expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory'); + }); +}); + +describe('getTopicDir', () => { + it('returns project/ under memory root', () => { + expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project'); + }); +}); diff --git a/apps/server/src/services/memory/__tests__/prompt.test.ts b/apps/server/src/services/memory/__tests__/prompt.test.ts new file mode 100644 index 0000000..59a72a7 --- /dev/null +++ b/apps/server/src/services/memory/__tests__/prompt.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { formatMemoryBlock } from '../prompt.js'; + +describe('formatMemoryBlock', () => { + it('wraps entries in boocode-memory tags', () => { + const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']); + expect(block).toContain(''); + expect(block).toContain('Use pnpm'); + expect(block).toContain(''); + }); + + it('returns empty string for no entries', () => { + expect(formatMemoryBlock([])).toBe(''); + }); +}); diff --git a/apps/server/src/services/memory/__tests__/recall.test.ts b/apps/server/src/services/memory/__tests__/recall.test.ts new file mode 100644 index 0000000..bc345e0 --- /dev/null +++ b/apps/server/src/services/memory/__tests__/recall.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { rankByRelevance } from '../recall.js'; +import type { MemoryEntry } from '../entries.js'; + +describe('rankByRelevance', () => { + it('returns entries matching query keywords', () => { + const entries: MemoryEntry[] = [ + { id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] }, + { id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] }, + ]; + const result = rankByRelevance('what indentation?', entries); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Style'); + }); +}); diff --git a/apps/server/src/services/memory/entries.ts b/apps/server/src/services/memory/entries.ts new file mode 100644 index 0000000..0e8bd66 --- /dev/null +++ b/apps/server/src/services/memory/entries.ts @@ -0,0 +1,54 @@ +export interface MemoryEntry { + id: string; + topic: string; + title: string; + content: string; + tags: string[]; +} + +export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] { + const entries: MemoryEntry[] = []; + const lines = markdown.split('\n'); + let currentEntry: Partial | null = null; + let currentContent: string[] = []; + + for (const line of lines) { + const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/); + if (headingMatch && headingMatch[1] && headingMatch[2]) { + if (currentEntry && currentEntry.title) { + entries.push({ + id: `${fileName}-${entries.length}`, + topic: currentEntry.topic ?? '', + title: currentEntry.title, + content: currentContent.join('\n').trim(), + tags: currentEntry.tags ?? [], + }); + } + currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] }; + currentContent = []; + continue; + } + + const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i); + if (tagsMatch && tagsMatch[1] && currentEntry) { + currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim()); + continue; + } + + if (currentEntry) { + currentContent.push(line); + } + } + + if (currentEntry && currentEntry.title) { + entries.push({ + id: `${fileName}-${entries.length}`, + topic: currentEntry.topic ?? '', + title: currentEntry.title, + content: currentContent.join('\n').trim(), + tags: currentEntry.tags ?? [], + }); + } + + return entries; +} diff --git a/apps/server/src/services/memory/index.ts b/apps/server/src/services/memory/index.ts new file mode 100644 index 0000000..9b1fae1 --- /dev/null +++ b/apps/server/src/services/memory/index.ts @@ -0,0 +1,6 @@ +export { loadMemoryForSession } from './recall.js'; +export { formatMemoryBlock } from './prompt.js'; +export { scanMemoryScopes } from './scan.js'; +export { parseMemoryEntries } from './entries.js'; +export { ensureMemoryScaffold, getMemoryRoot } from './paths.js'; +export type { MemoryEntry } from './entries.js'; diff --git a/apps/server/src/services/memory/paths.ts b/apps/server/src/services/memory/paths.ts new file mode 100644 index 0000000..026ab47 --- /dev/null +++ b/apps/server/src/services/memory/paths.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path'; +import { mkdir } from 'node:fs/promises'; + +const TOPICS = ['project', 'user', 'reference'] as const; +export type MemoryTopic = (typeof TOPICS)[number]; + +export function getMemoryRoot(projectRoot: string): string { + return join(projectRoot, '.boocode', 'memory'); +} + +export function getTopicDir(root: string, topic: MemoryTopic): string { + return join(root, topic); +} + +export async function ensureMemoryScaffold(root: string): Promise { + await Promise.all(TOPICS.map((t) => mkdir(join(root, t), { recursive: true }))); +} diff --git a/apps/server/src/services/memory/prompt.ts b/apps/server/src/services/memory/prompt.ts new file mode 100644 index 0000000..d565936 --- /dev/null +++ b/apps/server/src/services/memory/prompt.ts @@ -0,0 +1,5 @@ +export function formatMemoryBlock(entries: string[]): string { + if (entries.length === 0) return ''; + const body = entries.map((e) => `- ${e}`).join('\n'); + return `\n${body}\n`; +} diff --git a/apps/server/src/services/memory/recall.ts b/apps/server/src/services/memory/recall.ts new file mode 100644 index 0000000..bd50114 --- /dev/null +++ b/apps/server/src/services/memory/recall.ts @@ -0,0 +1,44 @@ +import type { MemoryEntry } from './entries.js'; +import { scanProjectMemory } from './scan.js'; + +function extractKeywords(query: string): string[] { + return query + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .split(/\s+/) + .filter((w) => w.length > 2); +} + +export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] { + const keywords = extractKeywords(query); + if (keywords.length === 0) return entries.slice(0, 5); + + const scored = entries.map((entry) => { + let score = 0; + const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase(); + for (const kw of keywords) { + if (entry.title.toLowerCase().includes(kw)) score += 3; + if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2; + if (entry.content.toLowerCase().includes(kw)) score += 1; + } + return { entry, score }; + }); + + return scored + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10) + .map((s) => s.entry); +} + +export async function loadMemoryForSession( + projectRoot: string, + _sessionId?: string, + query?: string, +): Promise { + const entries = await scanProjectMemory(projectRoot); + if (entries.length === 0) return []; + + const relevant = query ? rankByRelevance(query, entries) : entries.slice(0, 5); + return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`); +} diff --git a/apps/server/src/services/memory/scan.ts b/apps/server/src/services/memory/scan.ts new file mode 100644 index 0000000..15ede25 --- /dev/null +++ b/apps/server/src/services/memory/scan.ts @@ -0,0 +1,72 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { readFile, readdir } from 'node:fs/promises'; +import type { MemoryEntry } from './entries.js'; +import { parseMemoryEntries } from './entries.js'; +import { getMemoryRoot } from './paths.js'; + +export interface MemoryScope { + projectRoot: string; + sessionDir?: string; + homeDir?: string; +} + +async function scanDirectory(dir: string): Promise { + const entries: MemoryEntry[] = []; + try { + const files = await readdir(dir, { withFileTypes: true }); + for (const file of files) { + if (file.isFile() && file.name.endsWith('.md')) { + const content = await readFile(join(dir, file.name), 'utf8'); + entries.push(...parseMemoryEntries(file.name, content)); + } + } + } catch { + // Directory doesn't exist + } + return entries; +} + +const MEMORY_TOPICS = ['project', 'user', 'reference'] as const; + +async function scanTopicDirs(root: string): Promise { + const entries: MemoryEntry[] = []; + for (const topic of MEMORY_TOPICS) { + entries.push(...(await scanDirectory(join(root, topic)))); + } + return entries; +} + +export async function scanMemoryScopes(scope: MemoryScope): Promise { + const allEntries: MemoryEntry[] = []; + + // 1. Global (~/.boocode/memory/) - lowest priority + allEntries.push(...(await scanTopicDirs(getMemoryRoot(homedir())))); + + // 2. Home ($HOME/.boocode/memory) + const homeDir = scope.homeDir ?? homedir(); + const homeRoot = getMemoryRoot(homeDir); + if (homeRoot !== getMemoryRoot(homedir())) { + allEntries.push(...(await scanTopicDirs(homeRoot))); + } + + // 3. Project (.boocode/memory/ under project root) + allEntries.push(...(await scanTopicDirs(getMemoryRoot(scope.projectRoot)))); + + // 4. Session (.boocode/sessions//memory.md) - highest priority + if (scope.sessionDir) { + try { + const sessionFile = join(scope.sessionDir, 'memory.md'); + const content = await readFile(sessionFile, 'utf8'); + allEntries.push(...parseMemoryEntries('session-memory', content)); + } catch { + // No session memory file + } + } + + return allEntries; +} + +export async function scanProjectMemory(projectRoot: string): Promise { + return scanMemoryScopes({ projectRoot }); +} diff --git a/apps/server/src/services/memory/store.ts b/apps/server/src/services/memory/store.ts new file mode 100644 index 0000000..9347dde --- /dev/null +++ b/apps/server/src/services/memory/store.ts @@ -0,0 +1,35 @@ +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { MemoryTopic } from './paths.js'; +import { getTopicDir } from './paths.js'; + +export async function readTopicFiles(root: string, topic: MemoryTopic): Promise> { + const dir = getTopicDir(root, topic); + const files = new Map(); + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.md')) { + const content = await readFile(join(dir, entry.name), 'utf8'); + files.set(entry.name, content); + } + } + } catch { + // Directory doesn't exist yet + } + return files; +} + +export async function writeEntry( + root: string, + topic: MemoryTopic, + title: string, + content: string, + tags: string[], +): Promise { + const dir = getTopicDir(root, topic); + const tagLine = tags.length > 0 ? `> tags: ${tags.join(', ')}\n\n` : '\n'; + const entry = `## ${topic}: ${title}\n${tagLine}${content}\n`; + const filename = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.md'; + await writeFile(join(dir, filename), entry, 'utf8'); +} diff --git a/apps/server/src/services/system-prompt.ts b/apps/server/src/services/system-prompt.ts index b9a21d5..a1dde52 100644 --- a/apps/server/src/services/system-prompt.ts +++ b/apps/server/src/services/system-prompt.ts @@ -22,6 +22,8 @@ import { readFile, stat } from 'node:fs/promises'; import type { Agent, Project, Session } from '../types/api.js'; import { getAgentsMtimes } from './agents.js'; import { resolveRoute } from './inference/provider.js'; +import { loadMemoryForSession } from './memory/recall.js'; +import { formatMemoryBlock } from './memory/prompt.js'; const BASE_SYSTEM_PROMPT = (projectPath: string) => `You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`; @@ -164,7 +166,11 @@ export async function buildSystemPromptWithFingerprint( let out = BASE_SYSTEM_PROMPT(project.path); const guidance = await getContainerGuidance(); if (guidance) { - out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`; + out += '\n\n--- Container guidance ---\n' + guidance + '\n--- end container guidance ---\n'; + } + const memory = await loadMemoryForSession(project.path, session.id).catch(() => []); + if (memory.length > 0) { + out += '\n\n' + formatMemoryBlock(memory); } if (agent && agent.system_prompt.trim().length > 0) { out += '\n\n' + agent.system_prompt.trim(); From cdc782e0449edb68a298ea93beaccf143ecef1ba Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:49 +0000 Subject: [PATCH 09/11] feat(core): add subagent protocol enhancements - AgentCapabilitiesSchema with supportsStreaming/Reasoning/Background flags - supportsStreaming and supportsReasoningStream fields in ProviderSnapshotEntry - new_task tool: background mode flag for non-blocking subtask dispatch --- apps/coder/src/services/tools/new_task.ts | 8 +++++++- packages/contracts/src/agent-capabilities.ts | 9 +++++++++ packages/contracts/src/provider-snapshot.ts | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/agent-capabilities.ts diff --git a/apps/coder/src/services/tools/new_task.ts b/apps/coder/src/services/tools/new_task.ts index 026fcec..9867eed 100644 --- a/apps/coder/src/services/tools/new_task.ts +++ b/apps/coder/src/services/tools/new_task.ts @@ -6,6 +6,7 @@ const NewTaskInput = z.object({ input: z.string().min(1).describe('Task description for the child subtask'), agent: z.string().optional().describe('Optional: dispatch to a specific agent'), model: z.string().optional().describe('Optional: model override for the subtask'), + background: z.boolean().optional().describe('If true, return immediately without blocking on completion'), }); type NewTaskInputT = z.infer; @@ -30,6 +31,7 @@ export const newTaskTool: ToolDef = { input: { type: 'string', description: 'Task description for the child subtask' }, agent: { type: 'string', description: 'Optional: dispatch to a specific agent' }, model: { type: 'string', description: 'Optional: model override for the subtask' }, + background: { type: 'boolean', description: 'If true, returns immediately without waiting' }, }, required: ['input'], }, @@ -50,6 +52,7 @@ export const newTaskTool: ToolDef = { return { error: 'Cannot determine project_id from current session' }; } + const isBg = input.background === true; const [task] = await sql<{ id: string; state: string }[]>` INSERT INTO tasks (project_id, parent_task_id, input, agent, model) VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null}) @@ -57,9 +60,12 @@ export const newTaskTool: ToolDef = { `; return { - message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`, + message: isBg + ? `Background subtask created (id: ${task!.id}). It will continue independently.` + : `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`, task_id: task!.id, state: task!.state, + background: isBg, }; }, }; diff --git a/packages/contracts/src/agent-capabilities.ts b/packages/contracts/src/agent-capabilities.ts new file mode 100644 index 0000000..868743c --- /dev/null +++ b/packages/contracts/src/agent-capabilities.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const AgentCapabilitiesSchema = z.object({ + supportsStreaming: z.boolean().default(true), + supportsReasoningStream: z.boolean().default(false), + supportsBackgroundExecution: z.boolean().default(false), +}); + +export type AgentCapabilities = z.infer; diff --git a/packages/contracts/src/provider-snapshot.ts b/packages/contracts/src/provider-snapshot.ts index 36d3e7b..d2d83e6 100644 --- a/packages/contracts/src/provider-snapshot.ts +++ b/packages/contracts/src/provider-snapshot.ts @@ -49,4 +49,6 @@ export interface ProviderSnapshotEntry { commands: AgentCommand[]; error?: string; fetchedAt?: string; + supportsStreaming?: boolean; + supportsReasoningStream?: boolean; } From b64941ad4b8ee7b3ec9b52525712ed3aa2622f06 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:53 +0000 Subject: [PATCH 10/11] feat(coder): add plugin hook host - Typed hook registry with registerHook/emitHook/clearHooks - Hooks: tool.execute.before/after, turn.start/end, task.terminal - SUL patterns only (oh-my-openagent: architecture study, no code copy) --- apps/coder/src/plugins/host.ts | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/coder/src/plugins/host.ts diff --git a/apps/coder/src/plugins/host.ts b/apps/coder/src/plugins/host.ts new file mode 100644 index 0000000..f45f46f --- /dev/null +++ b/apps/coder/src/plugins/host.ts @@ -0,0 +1,42 @@ +export type HookName = + | 'tool.execute.before' + | 'tool.execute.after' + | 'turn.start' + | 'turn.end' + | 'task.terminal'; + +export interface ToolHookContext { + tool: string; + args: Record; + projectRoot: string; + sessionId: string; +} + +export interface ToolResultContext extends ToolHookContext { + result: unknown; +} + +export type PluginHook = (ctx: any) => Promise; + +const hooks = new Map(); + +export function registerHook(name: HookName, fn: PluginHook): void { + const list = hooks.get(name) || []; + list.push(fn); + hooks.set(name, list); +} + +export async function emitHook(name: HookName, ctx: any): Promise { + const list = hooks.get(name); + if (!list) return ctx; + let current = ctx; + for (const fn of list) { + const result = await fn(current); + if (result !== undefined) current = result; + } + return current; +} + +export function clearHooks(): void { + hooks.clear(); +} From 1b70d4199606fe8cfac9288959cedcede23948dc Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:58 +0000 Subject: [PATCH 11/11] feat(server): add inference reliability - tool-shim and loop detectors - ToolShim recovers XML/JSON tool calls from plain-text model output - detectContentRepeat catches same-content loops - detectToolLoop catches repeated tool invocations - detectDoomLoop combines both detectors --- .../src/services/inference/loop-detectors.ts | 68 +++++++++++++++++++ .../src/services/inference/tool-shim.ts | 45 ++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 apps/server/src/services/inference/loop-detectors.ts create mode 100644 apps/server/src/services/inference/tool-shim.ts diff --git a/apps/server/src/services/inference/loop-detectors.ts b/apps/server/src/services/inference/loop-detectors.ts new file mode 100644 index 0000000..94472b2 --- /dev/null +++ b/apps/server/src/services/inference/loop-detectors.ts @@ -0,0 +1,68 @@ +// Loop detectors — detects repetitive patterns in assistant output +// that indicate a model is stuck in a loop. + +export interface LoopDetectionResult { + isLoop: boolean; + reason?: string; + confidence: number; // 0-1 +} + +const REPEATED_PHRASE_MIN_COUNT = 4; +const REPEATED_TOOL_MIN_COUNT = 3; + +export function detectContentRepeat(messages: string[]): LoopDetectionResult { + if (messages.length < REPEATED_PHRASE_MIN_COUNT) { + return { isLoop: false, confidence: 0 }; + } + + const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT); + const unique = new Set(recent); + + if (unique.size === 1) { + return { + isLoop: true, + reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`, + confidence: 0.9, + }; + } + + if (unique.size <= 2 && recent.length >= 4) { + return { + isLoop: true, + reason: 'Content oscillating between two variants', + confidence: 0.7, + }; + } + + return { isLoop: false, confidence: 0 }; +} + +export function detectToolLoop(toolNames: string[]): LoopDetectionResult { + if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 }; + + const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT); + const unique = new Set(recent); + + if (unique.size === 1) { + return { + isLoop: true, + reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`, + confidence: 0.85, + }; + } + + return { isLoop: false, confidence: 0 }; +} + +export function detectDoomLoop( + messages: string[], + toolNames: string[], +): LoopDetectionResult { + const contentResult = detectContentRepeat(messages); + if (contentResult.isLoop) return contentResult; + + const toolResult = detectToolLoop(toolNames); + if (toolResult.isLoop) return toolResult; + + return { isLoop: false, confidence: 0 }; +} diff --git a/apps/server/src/services/inference/tool-shim.ts b/apps/server/src/services/inference/tool-shim.ts new file mode 100644 index 0000000..9b0bc16 --- /dev/null +++ b/apps/server/src/services/inference/tool-shim.ts @@ -0,0 +1,45 @@ +// ToolShim — recovers structured tool calls from plain-text model output. +// When the model emits tool calls as plain text instead of structured JSON, +// this shim attempts to parse and recover them. + +export interface ParsedToolCall { + id: string; + name: string; + arguments: string; +} + +const TOOL_CALL_PATTERN = /\s*(.+?)<\/name>\s*(.+?)<\/arguments>\s*<\/tool_call>/gs; +const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs; + +export function extractToolCalls(text: string): ParsedToolCall[] { + const calls: ParsedToolCall[] = []; + let match: RegExpExecArray | null; + + // Try XML-style tool calls (common in Qwen output) + const xmlRegex = new RegExp(TOOL_CALL_PATTERN); + while ((match = xmlRegex.exec(text)) !== null) { + calls.push({ + id: `call_${calls.length}`, + name: match[1]!.trim(), + arguments: match[2]!.trim(), + }); + } + + if (calls.length > 0) return calls; + + // Try JSON-style tool calls + const jsonRegex = new RegExp(JSON_TOOL_PATTERN); + while ((match = jsonRegex.exec(text)) !== null) { + calls.push({ + id: `call_${calls.length}`, + name: match[1]!.trim(), + arguments: match[2]!.trim(), + }); + } + + return calls; +} + +export function hasToolCallMarkup(text: string): boolean { + return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text); +}