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:
@@ -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<ViewFileInputT> = {
|
||||
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<ListDirInputT> = {
|
||||
? 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: "<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 {
|
||||
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<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
|
||||
// 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<AskUserInputInputT> = {
|
||||
// and TOOLS_BY_NAME inherit it.
|
||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
viewFile as ToolDef<unknown>,
|
||||
viewTruncatedOutput as ToolDef<unknown>,
|
||||
listDir as ToolDef<unknown>,
|
||||
grep 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.
|
||||
export const READ_ONLY_TOOL_NAMES = [
|
||||
'view_file',
|
||||
'view_truncated_output',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
|
||||
Reference in New Issue
Block a user