v1.13.5: opencode truncate.ts port — full tool output retrievable via opaque id
- New services/truncate.ts. Tmpfs storage at /tmp/boocode-truncations/ (BOOCODE_TRUNCATION_DIR env var overrides for tests). 12-char base32 opaque ids (~60 bits entropy, "tr_<id>"). Three exports: storeTruncation, readTruncation, truncateIfNeeded (wrap-or-passthrough helper). cleanupTruncations does TTL-pass (7 days) + orphan-reap (parts query on payload->'output'->>'outputPath') in one shot. - Wired four tools through truncateIfNeeded: view_file (raw full file), list_dir (full filtered+secret-filtered entries serialized one-per-line), web_fetch (textRaw pre-slice), codecontext_client (body.result pre-slice). Each returns the existing sliced view plus an optional outputPath field when truncation fires. - New view_truncated_output ToolDef. Resolves opaque id → on-disk content internally; model never sees the truncation dir. Same start_line / end_line slicing semantics as view_file. Registered in ALL_TOOLS (alpha sort places it after view_file automatically) and READ_ONLY_TOOL_NAMES. - cleanupTruncations piggybacks on the v1.13.3 stuck-row sweeper's 60s setInterval. No-op when truncation dir is empty. Not wired (TODO follow-up): grep and find_files. file_ops returns post-cap results to the tool execute path, so the "full content" isn't recoverable without a refactor of fileOps.grep / fileOps.findFiles to expose the uncapped result. web_search is silent-slice (no truncated flag); outside scope. Five sites of seven covered; the remaining two are the only ones needing a file_ops change. Tests: 7 new in truncate.test.ts (roundtrip, unknown id, malformed id, truncateIfNeeded false/true/over-cap/storage-failure paths). 186 total (was 179). cleanupTruncations file-system half implicitly via TTL pass; orphan-reap branch covered by the live container smoke. Smoke verified end-to-end against the live container: - view_file with start_line=1, end_line=3 on CLAUDE.md → tool_result part carried outputPath "tr_cdpn1o04k6ma" + truncated=true. - /tmp/boocode-truncations/tr_cdpn1o04k6ma exists, 15876 bytes, mode 0o600, parent dir mode 0o700. - Follow-up view_truncated_output(id, start_line=50, end_line=55) returned the actual lines 50-55 of CLAUDE.md (the 808notes/BooCode bullets). - ALL_TOOLS count=20 (was 19); alpha sort places view_truncated_output between view_file and watch_changes. Closes a v1.12 catalog row that was scoped but deferred. The v1.13 parts table made outputPath ride on the existing tool_result payload with no schema change beyond the storage helper itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { createBroker } from './services/broker.js';
|
|||||||
import { listSkills } from './services/skills.js';
|
import { listSkills } from './services/skills.js';
|
||||||
import * as compaction from './services/compaction.js';
|
import * as compaction from './services/compaction.js';
|
||||||
import { configureModelContext } from './services/model-context.js';
|
import { configureModelContext } from './services/model-context.js';
|
||||||
|
import { cleanupTruncations } from './services/truncate.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -238,7 +239,13 @@ async function main() {
|
|||||||
app.log.error({ err }, 'stuck-row sweeper failed');
|
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); });
|
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
|
|||||||
104
apps/server/src/services/__tests__/truncate.test.ts
Normal file
104
apps/server/src/services/__tests__/truncate.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
// which we re-surface with a hint to add the file to .codecontextignore.
|
// which we re-surface with a hint to add the file to .codecontextignore.
|
||||||
|
|
||||||
import { realpath } from 'node:fs/promises';
|
import { realpath } from 'node:fs/promises';
|
||||||
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
export interface CodecontextRequest {
|
export interface CodecontextRequest {
|
||||||
toolName: string;
|
toolName: string;
|
||||||
@@ -27,6 +28,9 @@ export interface CodecontextRequest {
|
|||||||
export interface CodecontextResponse {
|
export interface CodecontextResponse {
|
||||||
result: string;
|
result: string;
|
||||||
truncated: boolean;
|
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';
|
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
|
// 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.
|
// 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) {
|
if (body.result.length > TRUNCATION_LIMIT) {
|
||||||
const truncated = body.result.slice(0, TRUNCATION_LIMIT);
|
const truncated = body.result.slice(0, TRUNCATION_LIMIT);
|
||||||
const omitted = body.result.length - 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 {
|
return {
|
||||||
result:
|
result: wrapped.content,
|
||||||
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`,
|
truncated: wrapped.truncated,
|
||||||
truncated: true,
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { result: body.result, truncated: false };
|
return { result: body.result, truncated: false };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getGitMeta } from './git_meta.js';
|
|||||||
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||||
import { webSearch } from './web_search.js';
|
import { webSearch } from './web_search.js';
|
||||||
import { webFetch } from './web_fetch.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
|
// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from
|
||||||
// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts
|
// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts
|
||||||
// which talks to the codecontext sidecar at http://codecontext:8080.
|
// which talks to the codecontext sidecar at http://codecontext:8080.
|
||||||
@@ -109,12 +110,22 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
|||||||
const slice = lines.slice(start - 1, end);
|
const slice = lines.slice(start - 1, end);
|
||||||
const content = slice.join('\n');
|
const content = slice.join('\n');
|
||||||
const truncated = total > end || start > 1;
|
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 {
|
return {
|
||||||
path: relative(projectRoot, real) || basename(real),
|
path: relative(projectRoot, real) || basename(real),
|
||||||
content,
|
content: wrapped.content,
|
||||||
total_lines: total,
|
total_lines: total,
|
||||||
returned_lines: [start, end],
|
returned_lines: [start, end],
|
||||||
truncated,
|
truncated: wrapped.truncated,
|
||||||
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -157,41 +168,64 @@ export const listDir: ToolDef<ListDirInputT> = {
|
|||||||
? entries
|
? entries
|
||||||
: entries.filter((e) => !e.name.startsWith('.'));
|
: entries.filter((e) => !e.name.startsWith('.'));
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
const slice = filtered.slice(0, MAX_DIR_ENTRIES);
|
const wasTruncated = total > 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 relDir = relative(projectRoot, real) || '.';
|
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) =>
|
const secretFilter = filterSecretEntries(out, (e) =>
|
||||||
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
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: "<type>\t<name>[\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 {
|
return {
|
||||||
path: relDir,
|
path: relDir,
|
||||||
entries: secretFilter.kept,
|
entries: secretFilter.kept,
|
||||||
total: secretFilter.kept.length,
|
total: secretFilter.kept.length,
|
||||||
truncated: total > MAX_DIR_ENTRIES,
|
truncated: wasTruncated,
|
||||||
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||||
|
...(outputPath ? { outputPath } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -315,6 +349,71 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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<typeof ViewTruncatedOutputInput>;
|
||||||
|
|
||||||
|
export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
||||||
|
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
|
// 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's git state. No path input — operates on the inference-resolved
|
||||||
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
|
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
|
||||||
@@ -534,6 +633,7 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
|||||||
// and TOOLS_BY_NAME inherit it.
|
// and TOOLS_BY_NAME inherit it.
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
|
viewTruncatedOutput as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
grep as ToolDef<unknown>,
|
grep as ToolDef<unknown>,
|
||||||
findFiles as ToolDef<unknown>,
|
findFiles as ToolDef<unknown>,
|
||||||
@@ -570,6 +670,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
// project state, so it belongs in the read-only set for budget purposes.
|
// project state, so it belongs in the read-only set for budget purposes.
|
||||||
export const READ_ONLY_TOOL_NAMES = [
|
export const READ_ONLY_TOOL_NAMES = [
|
||||||
'view_file',
|
'view_file',
|
||||||
|
'view_truncated_output',
|
||||||
'list_dir',
|
'list_dir',
|
||||||
'grep',
|
'grep',
|
||||||
'find_files',
|
'find_files',
|
||||||
|
|||||||
170
apps/server/src/services/truncate.ts
Normal file
170
apps/server/src/services/truncate.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isPublicUrl } from './url_guard.js';
|
import { isPublicUrl } from './url_guard.js';
|
||||||
import type { ToolDef } from './tools.js';
|
import type { ToolDef } from './tools.js';
|
||||||
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
const WebFetchInput = z.object({
|
const WebFetchInput = z.object({
|
||||||
url: z.string().min(1).max(2048),
|
url: z.string().min(1).max(2048),
|
||||||
@@ -230,15 +231,24 @@ export async function executeWebFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const truncated = truncate(textRaw, maxChars);
|
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
|
// 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
|
// came from — useful for citations and for the model to reason about
|
||||||
// domain trust.
|
// domain trust.
|
||||||
return {
|
return {
|
||||||
url: currentUrl,
|
url: currentUrl,
|
||||||
title,
|
title,
|
||||||
content: truncated.content,
|
content: wrapped.content,
|
||||||
content_type: contentType,
|
content_type: contentType,
|
||||||
truncated: truncated.truncated,
|
truncated: wrapped.truncated,
|
||||||
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user