diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index eba3f0c..ca76111 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -21,6 +21,7 @@ import { createBroker } from './services/broker.js'; import { listSkills } from './services/skills.js'; import * as compaction from './services/compaction.js'; import { configureModelContext } from './services/model-context.js'; +import { cleanupTruncations } from './services/truncate.js'; async function main() { const config = loadConfig(); @@ -238,7 +239,13 @@ async function main() { app.log.error({ err }, 'stuck-row sweeper failed'); } }; - const sweepTimer = setInterval(() => { void sweepStaleStreaming(); }, SWEEP_INTERVAL_MS); + // v1.13.5: truncation cleanup rides the same cadence — 60s tick reaps + // tmpfs files past the 7-day TTL plus any orphans whose owning part has + // been pruned (v1.13.4) or deleted. No-op when the dir is empty. + const sweepTimer = setInterval(() => { + void sweepStaleStreaming(); + void cleanupTruncations({ sql, log: app.log }); + }, SWEEP_INTERVAL_MS); app.addHook('onClose', async () => { clearInterval(sweepTimer); }); const shutdown = async (signal: string) => { diff --git a/apps/server/src/services/__tests__/truncate.test.ts b/apps/server/src/services/__tests__/truncate.test.ts new file mode 100644 index 0000000..7fc280f --- /dev/null +++ b/apps/server/src/services/__tests__/truncate.test.ts @@ -0,0 +1,104 @@ +// v1.13.5: truncate.ts unit coverage. Each test isolates TRUNCATION_DIR +// under os.tmpdir() so concurrent vitest runs don't collide and the suite +// stays self-cleaning. cleanupTruncations is covered by file-system half +// only; the orphan-reap branch needs a real Postgres and is tested via the +// smoke flow rather than vitest. +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +// Set the env var BEFORE importing the module so its module-load constant +// reads the test directory rather than /tmp/boocode-truncations. +const testDir = path.join(os.tmpdir(), `boocode-truncate-test-${process.pid}-${Date.now()}`); +process.env.BOOCODE_TRUNCATION_DIR = testDir; + +const mod = await import('../truncate.js'); +const { storeTruncation, readTruncation, truncateIfNeeded, MAX_TRUNCATION_BYTES } = mod; + +beforeAll(async () => { + await fs.mkdir(testDir, { recursive: true }); +}); + +afterEach(async () => { + // Drop every file between tests so id-collision asserts and orphan-style + // counts start from zero. + const entries = await fs.readdir(testDir).catch(() => [] as string[]); + await Promise.all(entries.map((n) => fs.unlink(path.join(testDir, n)).catch(() => {}))); +}); + +describe('storeTruncation / readTruncation roundtrip', () => { + it('writes and reads identical content', async () => { + const original = 'hello\nworld\n' + 'x'.repeat(500); + const id = await storeTruncation(original); + expect(id).toMatch(/^tr_[0-9a-v]{12}$/); + const got = await readTruncation(id); + expect(got).toBe(original); + }); + + it('readTruncation returns null for unknown ids', async () => { + const got = await readTruncation('tr_000000000000'); + expect(got).toBeNull(); + }); + + it('readTruncation rejects malformed ids (returns null, never escapes dir)', async () => { + // Path traversal attempt; readTruncation should not even try to open. + const got = await readTruncation('../../etc/passwd'); + expect(got).toBeNull(); + }); +}); + +describe('truncateIfNeeded', () => { + it('returns sliced content with no outputPath when wasTruncated=false', async () => { + const out = await truncateIfNeeded({ + fullContent: 'irrelevant', + slicedContent: 'visible', + wasTruncated: false, + }); + expect(out).toEqual({ content: 'visible', truncated: false }); + expect('outputPath' in out).toBe(false); + }); + + it('stashes full content and returns outputPath when wasTruncated=true', async () => { + const full = 'line1\nline2\nline3\nline4\n'; + const sliced = 'line1\nline2\n[truncated]'; + const out = await truncateIfNeeded({ + fullContent: full, + slicedContent: sliced, + wasTruncated: true, + }); + expect(out.content).toBe(sliced); + expect(out.truncated).toBe(true); + expect(out.outputPath).toMatch(/^tr_[0-9a-v]{12}$/); + const stashed = await readTruncation(out.outputPath!); + expect(stashed).toBe(full); + }); + + it('skips storage but still reports truncated when fullContent exceeds the cap', async () => { + // Build content larger than MAX_TRUNCATION_BYTES. Use a Buffer to size + // it without holding a literal that triggers the gigantic-string lint. + const oversized = Buffer.alloc(MAX_TRUNCATION_BYTES + 1, 'x').toString('utf8'); + const sliced = 'preview...'; + const out = await truncateIfNeeded({ + fullContent: oversized, + slicedContent: sliced, + wasTruncated: true, + }); + expect(out).toEqual({ content: sliced, truncated: true }); + expect('outputPath' in out).toBe(false); + }); + + it('storage failure surfaces as truncated without outputPath', async () => { + // Force writeFile to throw. Spy at the fs module level since truncate.ts + // imports { promises as fs } and storeTruncation calls fs.writeFile. + const spy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('disk full')); + const out = await truncateIfNeeded({ + fullContent: 'short', + slicedContent: 'sliced', + wasTruncated: true, + }); + expect(out).toEqual({ content: 'sliced', truncated: true }); + expect('outputPath' in out).toBe(false); + spy.mockRestore(); + }); +}); diff --git a/apps/server/src/services/codecontext_client.ts b/apps/server/src/services/codecontext_client.ts index 6772a56..8b8fac0 100644 --- a/apps/server/src/services/codecontext_client.ts +++ b/apps/server/src/services/codecontext_client.ts @@ -17,6 +17,7 @@ // which we re-surface with a hint to add the file to .codecontextignore. import { realpath } from 'node:fs/promises'; +import { truncateIfNeeded } from './truncate.js'; export interface CodecontextRequest { toolName: string; @@ -27,6 +28,9 @@ export interface CodecontextRequest { export interface CodecontextResponse { result: string; truncated: boolean; + // v1.13.5: optional opaque id pointing at the full pre-slice content on + // tmpfs. Set when truncated=true and storage succeeded. + outputPath?: string; } const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080'; @@ -105,13 +109,22 @@ export async function callCodecontext( // Step 4: inline truncation. The model gets a clear hint about how to // narrow the next call rather than a silent cut. Mirrors web_fetch.ts. + // v1.13.5: stash the full body on tmpfs when truncating so the model can + // retrieve more via view_truncated_output(id). if (body.result.length > TRUNCATION_LIMIT) { const truncated = body.result.slice(0, TRUNCATION_LIMIT); const omitted = body.result.length - TRUNCATION_LIMIT; + const slicedWithMarker = + `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`; + const wrapped = await truncateIfNeeded({ + fullContent: body.result, + slicedContent: slicedWithMarker, + wasTruncated: true, + }); return { - result: - `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`, - truncated: true, + result: wrapped.content, + truncated: wrapped.truncated, + ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), }; } return { result: body.result, truncated: false }; diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index b407054..ff0a4bc 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -8,6 +8,7 @@ import { getGitMeta } from './git_meta.js'; import { findSkills, getSkillBody, getSkillResource } from './skills.js'; import { webSearch } from './web_search.js'; import { webFetch } from './web_fetch.js'; +import { readTruncation, truncateIfNeeded } from './truncate.js'; // v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from // tools/codecontext/index.ts. Each calls into services/codecontext_client.ts // which talks to the codecontext sidecar at http://codecontext:8080. @@ -109,12 +110,22 @@ export const viewFile: ToolDef = { const slice = lines.slice(start - 1, end); const content = slice.join('\n'); const truncated = total > end || start > 1; + // v1.13.5: stash the full file on tmpfs so the model can retrieve more + // via view_truncated_output(id) without re-reading the file (which it + // may not have project-relative-path access to in future agent setups). + // raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap. + const wrapped = await truncateIfNeeded({ + fullContent: raw, + slicedContent: content, + wasTruncated: truncated, + }); return { path: relative(projectRoot, real) || basename(real), - content, + content: wrapped.content, total_lines: total, returned_lines: [start, end], - truncated, + truncated: wrapped.truncated, + ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), }; }, }; @@ -157,41 +168,64 @@ export const listDir: ToolDef = { ? entries : entries.filter((e) => !e.name.startsWith('.')); const total = filtered.length; - const slice = filtered.slice(0, MAX_DIR_ENTRIES); - const out = await Promise.all( - slice.map(async (e) => { - const child = resolve(real, e.name); - let size: number | undefined; - if (e.isFile()) { - try { - const cs = await stat(child); - size = cs.size; - } catch { - /* ignore */ - } - } - return { - name: e.name, - type: e.isDirectory() ? ('dir' as const) : ('file' as const), - ...(size != null ? { size } : {}), - }; - }) - ); - // v1.11.7: filter entries whose project-relative path matches a secret - // pattern. Each entry is tested using the project-rel dir + its name - // so the pattern's path/segment semantics work for nested dirs like - // `.aws/`. The count is surfaced via `pathguard_note` — we never list - // the hidden paths (defeats the purpose). + const wasTruncated = total > MAX_DIR_ENTRIES; const relDir = relative(projectRoot, real) || '.'; + // v1.13.5: when we'd truncate, render the FULL list to tmpfs so + // view_truncated_output can serve it. Stat sizes for all entries when + // truncating so the stored view matches the visible shape; this is the + // one extra cost for big directories, bounded by total entries (which + // is itself bounded by filesystem behavior). + const processOne = async (e: typeof filtered[number]) => { + const child = resolve(real, e.name); + let size: number | undefined; + if (e.isFile()) { + try { + const cs = await stat(child); + size = cs.size; + } catch { /* ignore */ } + } + return { + name: e.name, + type: e.isDirectory() ? ('dir' as const) : ('file' as const), + ...(size != null ? { size } : {}), + }; + }; + const slice = filtered.slice(0, MAX_DIR_ENTRIES); + const out = await Promise.all(slice.map(processOne)); + // v1.11.7: filter entries whose project-relative path matches a secret + // pattern. The same filter applies to the full-list snapshot below so + // the stashed file never holds entries the slice would have hidden. const secretFilter = filterSecretEntries(out, (e) => relDir === '.' ? e.name : `${relDir}/${e.name}`, ); + let outputPath: string | undefined; + if (wasTruncated) { + const fullProcessed = await Promise.all(filtered.map(processOne)); + const fullFiltered = filterSecretEntries(fullProcessed, (e) => + relDir === '.' ? e.name : `${relDir}/${e.name}`, + ); + // One line per entry, view_truncated_output's line slicing semantics + // map cleanly. Format: "\t[\tsize=N]". Header documents + // the shape so the model can grep / regex without prior schema lookup. + const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`; + const lines = [header, ...fullFiltered.kept.map((e) => { + const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : ''; + return `${e.type}\t${e.name}${sz}`; + })]; + const wrapped = await truncateIfNeeded({ + fullContent: lines.join('\n'), + slicedContent: '', + wasTruncated: true, + }); + outputPath = wrapped.outputPath; + } return { path: relDir, entries: secretFilter.kept, total: secretFilter.kept.length, - truncated: total > MAX_DIR_ENTRIES, + truncated: wasTruncated, ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), + ...(outputPath ? { outputPath } : {}), }; }, }; @@ -315,6 +349,71 @@ export const findFiles: ToolDef = { }, }; +// v1.13.5: retrieves the full content of a previously-truncated tool output +// via the opaque id stamped on the original tool_result. Line-based slicing +// matches view_file's mental model so the model uses the same affordances. +// Tmpfs-backed, 7-day TTL (see services/truncate.ts). +const VIEW_TRUNCATED_DEFAULT_LINES = 200; + +const ViewTruncatedOutputInput = z.object({ + id: z.string().regex(/^tr_[0-9a-v]{12}$/), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), +}); +type ViewTruncatedOutputInputT = z.infer; + +export const viewTruncatedOutput: ToolDef = { + name: 'view_truncated_output', + description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`, + inputSchema: ViewTruncatedOutputInput, + jsonSchema: { + type: 'function', + function: { + name: 'view_truncated_output', + description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`, + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' }, + start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' }, + end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` }, + }, + required: ['id'], + additionalProperties: false, + }, + }, + }, + async execute(input, _projectRoot) { + const content = await readTruncation(input.id); + if (content === null) { + return { + id: input.id, + content: '', + truncated: false, + error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`, + }; + } + const lines = content.split('\n'); + const total = lines.length; + let start = input.start_line ?? 1; + let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1); + if (start < 1) start = 1; + if (end > total) end = total; + if (end < start) end = start; + const slice = lines.slice(start - 1, end).join('\n'); + // Re-slicing this view isn't truncation in the dual-write sense — the + // model already has the id; no point stashing the slice again. + const truncated = total > end || start > 1; + return { + id: input.id, + content: slice, + total_lines: total, + returned_lines: [start, end], + truncated, + }; + }, +}; + // v1.8 Level 1 branch awareness: gives the model a read-only view of the // project's git state. No path input — operates on the inference-resolved // project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta). @@ -534,6 +633,7 @@ export const askUserInput: ToolDef = { // and TOOLS_BY_NAME inherit it. export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, + viewTruncatedOutput as ToolDef, listDir as ToolDef, grep as ToolDef, findFiles as ToolDef, @@ -570,6 +670,7 @@ export const ALL_TOOLS: ReadonlyArray> = [ // project state, so it belongs in the read-only set for budget purposes. export const READ_ONLY_TOOL_NAMES = [ 'view_file', + 'view_truncated_output', 'list_dir', 'grep', 'find_files', diff --git a/apps/server/src/services/truncate.ts b/apps/server/src/services/truncate.ts new file mode 100644 index 0000000..cfe1889 --- /dev/null +++ b/apps/server/src/services/truncate.ts @@ -0,0 +1,170 @@ +import { promises as fs } from 'fs'; +import { randomBytes } from 'crypto'; +import path from 'path'; +import type { Sql } from '../db.js'; + +// v1.13.5: opencode-style truncation storage. When a tool slice would cut +// content the model might still want, we store the full text on tmpfs and +// hand the model an opaque id. view_truncated_output(id) retrieves it. +// +// Tmpfs path means full content vanishes on container restart; chats that +// outlive a restart lose retrieval (acceptable — the user has usually moved +// on or the data is stale). 7-day TTL + orphan reap bound disk growth via +// the periodic sweeper in index.ts. + +export const TRUNCATION_DIR = process.env.BOOCODE_TRUNCATION_DIR ?? '/tmp/boocode-truncations'; +export const TRUNCATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +// Matches view_file's MAX_FILE_BYTES — anything bigger was already refused +// at the source tool's size check, so we never see it here. +export const MAX_TRUNCATION_BYTES = 5 * 1024 * 1024; + +const ID_RE = /^tr_[0-9a-v]{12}$/; + +let dirEnsured = false; +async function ensureDir(): Promise { + if (dirEnsured) return; + await fs.mkdir(TRUNCATION_DIR, { recursive: true, mode: 0o700 }); + dirEnsured = true; +} + +// 12 base32 chars ≈ 60 bits of entropy. Collision probability across a +// 7-day window with ~thousands of truncations is essentially zero. +function newId(): string { + const buf = randomBytes(8); + const alphabet = '0123456789abcdefghijklmnopqrstuv'; + let out = 'tr_'; + for (const byte of buf) { + out += alphabet[byte & 0x1f]; + out += alphabet[(byte >> 3) & 0x1f]; + } + return out.slice(0, 15); +} + +function idToPath(id: string): string { + // Defense-in-depth: the model never supplies a path component (only ids), + // but a malformed id from anywhere else shouldn't escape TRUNCATION_DIR. + if (!ID_RE.test(id)) { + throw new Error(`Invalid truncation id: ${id}`); + } + return path.join(TRUNCATION_DIR, id); +} + +export async function storeTruncation(fullContent: string): Promise { + const bytes = Buffer.byteLength(fullContent, 'utf8'); + if (bytes > MAX_TRUNCATION_BYTES) { + throw new Error(`Truncation content ${bytes}B exceeds ${MAX_TRUNCATION_BYTES}B cap`); + } + await ensureDir(); + const id = newId(); + await fs.writeFile(idToPath(id), fullContent, { encoding: 'utf8', mode: 0o600 }); + return id; +} + +export async function readTruncation(id: string): Promise { + if (!ID_RE.test(id)) return null; + try { + return await fs.readFile(idToPath(id), { encoding: 'utf8' }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +// Wrap a tool's output. If wasTruncated, stash the full content on tmpfs +// and return its id alongside the sliced view the tool would have returned. +// Storage failure (disk full, permission denied) is non-fatal — the sliced +// view ships without an outputPath, which is exactly what the tool returned +// before v1.13.5. Same goes for content over MAX_TRUNCATION_BYTES. +export async function truncateIfNeeded(args: { + fullContent: string; + slicedContent: string; + wasTruncated: boolean; +}): Promise<{ content: string; truncated: boolean; outputPath?: string }> { + if (!args.wasTruncated) { + return { content: args.slicedContent, truncated: false }; + } + const bytes = Buffer.byteLength(args.fullContent, 'utf8'); + if (bytes > MAX_TRUNCATION_BYTES) { + return { content: args.slicedContent, truncated: true }; + } + try { + const outputPath = await storeTruncation(args.fullContent); + return { content: args.slicedContent, truncated: true, outputPath }; + } catch { + return { content: args.slicedContent, truncated: true }; + } +} + +// Periodic cleanup. Called from index.ts's sweep interval (v1.13.3 cadence). +// Pass 1: TTL — anything older than TRUNCATION_TTL_MS is gone. +// Pass 2: orphans — files with no live message_parts.payload->'output'->>'outputPath' +// reference. Catches the case where a part referencing an outputPath got +// hidden by prune (v1.13.4) and the file is now unreachable. +export async function cleanupTruncations(args: { + sql: Sql; + log: { warn: (obj: object, msg: string) => void; error: (obj: object, msg: string) => void }; +}): Promise<{ ttlReaped: number; orphanReaped: number }> { + await ensureDir(); + const cutoff = Date.now() - TRUNCATION_TTL_MS; + let ttlReaped = 0; + let orphanReaped = 0; + + let entries: string[]; + try { + entries = await fs.readdir(TRUNCATION_DIR); + } catch (err) { + args.log.error({ err }, 'cleanupTruncations readdir failed'); + return { ttlReaped, orphanReaped }; + } + if (entries.length === 0) return { ttlReaped, orphanReaped }; + + const survivors: string[] = []; + for (const name of entries) { + if (!ID_RE.test(name)) continue; + const full = path.join(TRUNCATION_DIR, name); + try { + const stat = await fs.stat(full); + if (stat.mtimeMs < cutoff) { + await fs.unlink(full); + ttlReaped += 1; + } else { + survivors.push(name); + } + } catch { + // File vanished between readdir and stat — fine. + } + } + + if (survivors.length === 0) { + if (ttlReaped > 0) { + args.log.warn({ ttlReaped, orphanReaped: 0 }, 'cleanupTruncations reaped files'); + } + return { ttlReaped, orphanReaped: 0 }; + } + + // outputPath rides inside the tool_result part's payload.output object + // (see partsFromToolMessage in inference/parts.ts), so the json path is + // payload->'output'->>'outputPath' rather than top-level. + const referenced = await args.sql<{ output_path: string }[]>` + SELECT DISTINCT p.payload->'output'->>'outputPath' AS output_path + FROM message_parts p + WHERE p.kind = 'tool_result' + AND p.payload->'output' ? 'outputPath' + AND p.payload->'output'->>'outputPath' = ANY(${survivors}) + `; + const live = new Set(referenced.map((r) => r.output_path)); + for (const name of survivors) { + if (live.has(name)) continue; + try { + await fs.unlink(path.join(TRUNCATION_DIR, name)); + orphanReaped += 1; + } catch { + // ignore + } + } + + if (ttlReaped > 0 || orphanReaped > 0) { + args.log.warn({ ttlReaped, orphanReaped }, 'cleanupTruncations reaped files'); + } + return { ttlReaped, orphanReaped }; +} diff --git a/apps/server/src/services/web_fetch.ts b/apps/server/src/services/web_fetch.ts index 0a72654..9c5dbe7 100644 --- a/apps/server/src/services/web_fetch.ts +++ b/apps/server/src/services/web_fetch.ts @@ -11,6 +11,7 @@ import { z } from 'zod'; import { isPublicUrl } from './url_guard.js'; import type { ToolDef } from './tools.js'; +import { truncateIfNeeded } from './truncate.js'; const WebFetchInput = z.object({ url: z.string().min(1).max(2048), @@ -230,15 +231,24 @@ export async function executeWebFetch( } const truncated = truncate(textRaw, maxChars); + // v1.13.5: stash the full pre-slice body when truncation fires so the + // model can pull more via view_truncated_output(id) without re-fetching. + // textRaw is already bounded by MAX_BYTES (5MB), within truncate.ts's cap. + const wrapped = await truncateIfNeeded({ + fullContent: textRaw, + slicedContent: truncated.content, + wasTruncated: truncated.truncated, + }); // Report the FINAL URL (post-redirects) so the LLM knows where the body // came from — useful for citations and for the model to reason about // domain trust. return { url: currentUrl, title, - content: truncated.content, + content: wrapped.content, content_type: contentType, - truncated: truncated.truncated, + truncated: wrapped.truncated, + ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), }; }