Compare commits
3 Commits
ac1a71f583
...
v1.13.3-tr
| Author | SHA1 | Date | |
|---|---|---|---|
| f8fc5db929 | |||
| ec8593cf77 | |||
| a08d809b73 |
@@ -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();
|
||||||
@@ -201,6 +202,52 @@ async function main() {
|
|||||||
app.log.info(`serving static frontend from ${webDist}`);
|
app.log.info(`serving static frontend from ${webDist}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.3: periodic in-process sweeper for streaming rows orphaned by a
|
||||||
|
// mid-session crash. The boot sweep (above) only fires once at startup;
|
||||||
|
// this loop catches the in-flight case. 60s cadence + 5-min threshold
|
||||||
|
// matches the boot sweep so behavior is consistent. Publishes
|
||||||
|
// chat_status='idle' on the user channel so the UI dot drops without a
|
||||||
|
// refresh — same pattern as handleAbortOrError.
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
|
const sweepStaleStreaming = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const rows = await sql<{ id: string; chat_id: string }[]>`
|
||||||
|
UPDATE messages
|
||||||
|
SET status = 'failed', finished_at = clock_timestamp()
|
||||||
|
WHERE status = 'streaming'
|
||||||
|
AND created_at < NOW() - INTERVAL '5 minutes'
|
||||||
|
RETURNING id, chat_id
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
app.log.warn(
|
||||||
|
{ swept: rows.length, ids: rows.map((r) => r.id) },
|
||||||
|
'swept stale streaming rows',
|
||||||
|
);
|
||||||
|
const seenChats = new Set<string>();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (seenChats.has(row.chat_id)) continue;
|
||||||
|
seenChats.add(row.chat_id);
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: row.chat_id,
|
||||||
|
status: 'idle',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'stuck-row sweeper failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 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) => {
|
const shutdown = async (signal: string) => {
|
||||||
app.log.info(`received ${signal}, shutting down`);
|
app.log.info(`received ${signal}, shutting down`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
-- v1.13.3: statement_timeout is set at database level via:
|
||||||
|
-- ALTER DATABASE boocode SET statement_timeout = '30s';
|
||||||
|
-- ALTER DATABASE can't run inside a DO block, so this is an operational
|
||||||
|
-- step rather than schema. Re-apply after a volume reset (the setting
|
||||||
|
-- lives in pg_db which survives `docker compose up --build` but NOT a
|
||||||
|
-- `docker volume rm boocode_pgdata`).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -49,6 +56,24 @@ CREATE TABLE IF NOT EXISTS message_parts (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
||||||
|
|
||||||
|
-- v1.13.4: prune support. hidden_at marks parts that have been pruned out
|
||||||
|
-- of the model payload by the two-tier compaction prune (services/inference/
|
||||||
|
-- prune.ts). Rows stay in the DB so frontend can still display them with a
|
||||||
|
-- "hidden" indicator (out of scope this dispatch). messages_with_parts
|
||||||
|
-- view filters these out — see below. Partial index speeds the common
|
||||||
|
-- "visible parts only" filter.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'message_parts' AND column_name = 'hidden_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE message_parts ADD COLUMN hidden_at timestamptz NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
|
||||||
|
ON message_parts (message_id) WHERE hidden_at IS NULL;
|
||||||
|
|
||||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||||
@@ -66,23 +91,32 @@ SELECT
|
|||||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||||
m.summary, m.tail_start_id, m.compacted_at,
|
m.summary, m.tail_start_id, m.compacted_at,
|
||||||
COALESCE(
|
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
||||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
||||||
FROM message_parts p
|
-- (prune intended — return null/empty so the row drops from the model
|
||||||
WHERE p.message_id = m.id AND p.kind = 'tool_call'),
|
-- payload). A naive COALESCE would fall back to the legacy column when
|
||||||
m.tool_calls
|
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
||||||
) AS tool_calls,
|
-- splits the two cases.
|
||||||
COALESCE(
|
CASE
|
||||||
(SELECT p.payload
|
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||||
FROM message_parts p
|
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
||||||
WHERE p.message_id = m.id AND p.kind = 'tool_result'
|
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||||
ORDER BY p.sequence
|
FROM message_parts p
|
||||||
LIMIT 1),
|
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
||||||
m.tool_results
|
ELSE m.tool_calls
|
||||||
) AS tool_results,
|
END AS tool_calls,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||||
|
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
|
||||||
|
THEN (SELECT p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||||
|
ORDER BY p.sequence LIMIT 1)
|
||||||
|
ELSE m.tool_results
|
||||||
|
END AS tool_results,
|
||||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||||
FROM message_parts p
|
FROM message_parts p
|
||||||
WHERE p.message_id = m.id AND p.kind = 'reasoning') AS reasoning_parts
|
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
||||||
FROM messages m;
|
FROM messages m;
|
||||||
|
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
|
||||||
|
|||||||
96
apps/server/src/services/__tests__/prune.test.ts
Normal file
96
apps/server/src/services/__tests__/prune.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
selectPruneTargets,
|
||||||
|
PROTECTED_TOKENS,
|
||||||
|
PRUNE_TRIGGER_TOKENS,
|
||||||
|
type PartForPrune,
|
||||||
|
} from '../inference/prune.js';
|
||||||
|
|
||||||
|
// Test fixture: build a tool_result part whose payload size yields a known
|
||||||
|
// token estimate (chars/4). The decision logic only cares about
|
||||||
|
// JSON.stringify(payload).length, so a string payload of `4n` chars
|
||||||
|
// produces exactly `n` tokens.
|
||||||
|
let seq = 0;
|
||||||
|
function part(tokens: number, createdAt: Date): PartForPrune {
|
||||||
|
seq += 1;
|
||||||
|
// JSON.stringify("xxx...") wraps in quotes (adds 2 chars), so subtract 2
|
||||||
|
// before multiplying. Math.ceil((len+2)/4) needs len ≈ 4*tokens - 2 so the
|
||||||
|
// total stringified length is 4*tokens. Approximate by padding 4 chars per
|
||||||
|
// token; the off-by-one from quotes is small and tests check totals, not
|
||||||
|
// exact per-part counts.
|
||||||
|
const text = 'x'.repeat(tokens * 4 - 2);
|
||||||
|
return { id: `p${seq}`, payload: text, created_at: createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const T_NOW = new Date('2026-05-22T12:00:00Z');
|
||||||
|
function ago(secondsBack: number): Date {
|
||||||
|
return new Date(T_NOW.getTime() - secondsBack * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectPruneTargets', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
seq = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when there are no parts', () => {
|
||||||
|
expect(selectPruneTargets([], null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when total tokens are under the protection window', () => {
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(10_000, ago(10)),
|
||||||
|
part(10_000, ago(20)),
|
||||||
|
]; // 20k total, all protected
|
||||||
|
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when candidate total is below the prune trigger', () => {
|
||||||
|
// Protection fills with ~40k newest, candidates only ~5k. Below 20k trigger.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(20_000, ago(10)),
|
||||||
|
part(20_000, ago(20)),
|
||||||
|
// Past protection; total ~5k won't trigger.
|
||||||
|
part(5_000, ago(30)),
|
||||||
|
];
|
||||||
|
const result = selectPruneTargets(parts, null);
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
expect(result.freedTokens).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides candidates past protection when their total clears the trigger', () => {
|
||||||
|
// Newest 40k protected; older 30k cleanly above the 20k trigger.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(20_000, ago(10)),
|
||||||
|
part(20_000, ago(20)),
|
||||||
|
// Past protection, total ~30k freed.
|
||||||
|
part(15_000, ago(30)),
|
||||||
|
part(15_000, ago(40)),
|
||||||
|
];
|
||||||
|
const result = selectPruneTargets(parts, null);
|
||||||
|
expect(result.ids).toEqual(['p3', 'p4']);
|
||||||
|
expect(result.freedTokens).toBeGreaterThanOrEqual(PRUNE_TRIGGER_TOKENS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the compaction summary boundary', () => {
|
||||||
|
// Newest 30k protected (just under PROTECTED_TOKENS=40k); then 30k of
|
||||||
|
// older parts. Boundary sits at ago(35), so the ago(40) part is
|
||||||
|
// beyond it and gets skipped.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(15_000, ago(10)),
|
||||||
|
part(15_000, ago(20)),
|
||||||
|
part(15_000, ago(30)), // crosses protection threshold; candidate
|
||||||
|
part(15_000, ago(40)), // beyond summary boundary; skipped
|
||||||
|
];
|
||||||
|
const tailStart = ago(35);
|
||||||
|
const result = selectPruneTargets(parts, tailStart);
|
||||||
|
// ago(30) is the only candidate inside the window; 15k is below the
|
||||||
|
// 20k trigger so we expect no hides.
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prune when only protected parts exist (no candidates)', () => {
|
||||||
|
// Exactly PROTECTED_TOKENS of newest parts; no older candidates.
|
||||||
|
const parts: PartForPrune[] = [part(PROTECTED_TOKENS, ago(10))];
|
||||||
|
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/server/src/services/__tests__/tools.test.ts
Normal file
14
apps/server/src/services/__tests__/tools.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
|
|
||||||
|
describe('ALL_TOOLS registry', () => {
|
||||||
|
// v1.13.3: tools must be alpha-sorted at module load. llama.cpp's prompt
|
||||||
|
// cache hits on byte-identical prefixes; the tool list lives near the
|
||||||
|
// top of the system prompt, so any order drift invalidates every cached
|
||||||
|
// turn. The registry sort is the single source of truth; downstream
|
||||||
|
// helpers (toolJsonSchemas, TOOLS_BY_NAME, buildAiTools) inherit it.
|
||||||
|
it('exports tools in alphabetical order by name', () => {
|
||||||
|
const names = ALL_TOOLS.map((t) => t.name);
|
||||||
|
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||||
|
});
|
||||||
|
});
|
||||||
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 type {
|
|||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import { buildSystemPrompt } from '../system-prompt.js';
|
import { buildSystemPrompt } from '../system-prompt.js';
|
||||||
import { isAnySentinel } from './sentinels.js';
|
import { isAnySentinel } from './sentinels.js';
|
||||||
|
import { PRUNE_TRIGGER_TOKENS, prune } from './prune.js';
|
||||||
import type { InferenceContext } from './turn.js';
|
import type { InferenceContext } from './turn.js';
|
||||||
|
|
||||||
export interface OpenAiMessage {
|
export interface OpenAiMessage {
|
||||||
@@ -166,6 +167,26 @@ export async function maybeFlagForCompaction(
|
|||||||
contextLimit,
|
contextLimit,
|
||||||
);
|
);
|
||||||
if (!overflow) return;
|
if (!overflow) return;
|
||||||
|
|
||||||
|
// v1.13.4: try the cheap prune first. If it freed at least the buffer
|
||||||
|
// worth of tokens (PRUNE_TRIGGER_TOKENS, identical to COMPACTION_BUFFER),
|
||||||
|
// we're below the threshold again — skip flagging summarize for the next
|
||||||
|
// turn. The next turn's overflow check will re-evaluate from scratch.
|
||||||
|
// Prune failures (DB errors etc.) propagate so the surrounding inference
|
||||||
|
// path sees them; the catch in finalizeCompletion / executeToolPhase
|
||||||
|
// doesn't shield this — by design, we want to know if prune is broken.
|
||||||
|
const pruned = await prune({ sql: ctx.sql, chatId });
|
||||||
|
if (pruned.hidden > 0) {
|
||||||
|
ctx.log.info(
|
||||||
|
{ chatId, hidden: pruned.hidden, freedTokens: pruned.freedTokens },
|
||||||
|
'inference: prune freed context budget',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pruned.freedTokens >= PRUNE_TRIGGER_TOKENS) {
|
||||||
|
// Prune handled it; skip the (expensive) summarize path.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.sql`UPDATE chats SET needs_compaction = true WHERE id = ${chatId}`;
|
await ctx.sql`UPDATE chats SET needs_compaction = true WHERE id = ${chatId}`;
|
||||||
ctx.log.info({ chatId, promptTokens, completionTokens, contextLimit }, 'inference: flagged for compaction');
|
ctx.log.info({ chatId, promptTokens, completionTokens, contextLimit }, 'inference: flagged for compaction');
|
||||||
}
|
}
|
||||||
|
|||||||
127
apps/server/src/services/inference/prune.ts
Normal file
127
apps/server/src/services/inference/prune.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
// v1.13.4: two-tier compaction prune. Opencode's prune half (the cheap one);
|
||||||
|
// summarize half shipped in v1.11.0 as services/compaction.ts.
|
||||||
|
//
|
||||||
|
// Algorithm: scan tool_result parts newest-first. Protect the last
|
||||||
|
// PROTECTED_TOKENS of content (the model recently saw these — pruning them
|
||||||
|
// kills coherence). Older parts are candidates. Mark them hidden_at only
|
||||||
|
// if the candidate pool would free at least PRUNE_TRIGGER_TOKENS — pruning
|
||||||
|
// 3 small tool_results to recover 500 tokens isn't worth the loss of
|
||||||
|
// fidelity for the model's next turn.
|
||||||
|
//
|
||||||
|
// Stops at the last compaction summary boundary (chats.tail_start_id). The
|
||||||
|
// v1.11.0 summary already encodes everything before that point; pruning
|
||||||
|
// across the boundary would double-erase.
|
||||||
|
|
||||||
|
export const PROTECTED_TOKENS = 40_000;
|
||||||
|
export const PRUNE_TRIGGER_TOKENS = 20_000;
|
||||||
|
|
||||||
|
// Rough char-to-token estimate. Same heuristic compaction's usable() uses
|
||||||
|
// implicitly via the buffer constant.
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadTokens(payload: unknown): number {
|
||||||
|
return estimateTokens(JSON.stringify(payload ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneResult {
|
||||||
|
hidden: number;
|
||||||
|
freedTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure algorithmic core, exported for unit-test access. Takes parts already
|
||||||
|
// ordered newest-first, plus an optional cutoff (last compaction summary
|
||||||
|
// boundary). Returns the part ids to hide and the total token estimate of
|
||||||
|
// the candidates. Caller does the DB UPDATE.
|
||||||
|
export interface PartForPrune {
|
||||||
|
id: string;
|
||||||
|
payload: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPruneTargets(
|
||||||
|
partsNewestFirst: ReadonlyArray<PartForPrune>,
|
||||||
|
tailStartCreatedAt: Date | null,
|
||||||
|
): { ids: string[]; freedTokens: number } {
|
||||||
|
let protectedTokens = 0;
|
||||||
|
const candidates: { id: string; tokens: number }[] = [];
|
||||||
|
let crossedProtection = false;
|
||||||
|
|
||||||
|
for (const part of partsNewestFirst) {
|
||||||
|
if (tailStartCreatedAt && part.created_at < tailStartCreatedAt) {
|
||||||
|
// Past the last summary boundary; the v1.11.0 anchored summary already
|
||||||
|
// covers everything older. Bail rather than double-erase.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const tokens = payloadTokens(part.payload);
|
||||||
|
if (!crossedProtection) {
|
||||||
|
protectedTokens += tokens;
|
||||||
|
if (protectedTokens >= PROTECTED_TOKENS) {
|
||||||
|
crossedProtection = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({ id: part.id, tokens });
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateTokens = candidates.reduce((s, c) => s + c.tokens, 0);
|
||||||
|
if (candidates.length === 0 || candidateTokens < PRUNE_TRIGGER_TOKENS) {
|
||||||
|
return { ids: [], freedTokens: 0 };
|
||||||
|
}
|
||||||
|
return { ids: candidates.map((c) => c.id), freedTokens: candidateTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prune(args: {
|
||||||
|
sql: Sql;
|
||||||
|
chatId: string;
|
||||||
|
}): Promise<PruneResult> {
|
||||||
|
const { sql, chatId } = args;
|
||||||
|
|
||||||
|
// Newest-first scan of visible tool_result parts in this chat. Pull
|
||||||
|
// chats.tail_start_id alongside so we know where the last summary boundary
|
||||||
|
// sits (don't prune across it).
|
||||||
|
const parts = await sql<{
|
||||||
|
id: string;
|
||||||
|
payload: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
tail_start_id: string | null;
|
||||||
|
}[]>`
|
||||||
|
SELECT p.id, p.payload, m.created_at,
|
||||||
|
(SELECT c.tail_start_id FROM chats c WHERE c.id = ${chatId}) AS tail_start_id
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${chatId}
|
||||||
|
AND p.kind = 'tool_result'
|
||||||
|
AND p.hidden_at IS NULL
|
||||||
|
ORDER BY m.created_at DESC, p.sequence DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return { hidden: 0, freedTokens: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the boundary cutoff timestamp once. Older messages are off-limits.
|
||||||
|
let tailStartCreatedAt: Date | null = null;
|
||||||
|
const firstTailId = parts[0]?.tail_start_id ?? null;
|
||||||
|
if (firstTailId) {
|
||||||
|
const tailRow = await sql<{ created_at: Date }[]>`
|
||||||
|
SELECT created_at FROM messages WHERE id = ${firstTailId}
|
||||||
|
`;
|
||||||
|
tailStartCreatedAt = tailRow[0]?.created_at ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = selectPruneTargets(parts, tailStartCreatedAt);
|
||||||
|
if (decision.ids.length === 0) {
|
||||||
|
return { hidden: 0, freedTokens: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE message_parts
|
||||||
|
SET hidden_at = clock_timestamp()
|
||||||
|
WHERE id = ANY(${decision.ids})
|
||||||
|
`;
|
||||||
|
return { hidden: decision.ids.length, freedTokens: decision.freedTokens };
|
||||||
|
}
|
||||||
@@ -19,7 +19,14 @@ import type {
|
|||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
import { upstreamModel } from './provider.js';
|
import { upstreamModel } from './provider.js';
|
||||||
import { jsonSchema, streamText, tool, type JSONValue, type ModelMessage } from 'ai';
|
import {
|
||||||
|
jsonSchema,
|
||||||
|
streamText,
|
||||||
|
tool,
|
||||||
|
type JSONValue,
|
||||||
|
type ModelMessage,
|
||||||
|
type ToolCallRepairFunction,
|
||||||
|
} from 'ai';
|
||||||
|
|
||||||
interface StreamOptions {
|
interface StreamOptions {
|
||||||
// null = omit tools entirely (compact phase); [] = caller stripped all tools
|
// null = omit tools entirely (compact phase); [] = caller stripped all tools
|
||||||
@@ -155,10 +162,36 @@ export async function streamCompletion(
|
|||||||
// Replaces the v1.13.1-A counter-only diagnostic.
|
// Replaces the v1.13.1-A counter-only diagnostic.
|
||||||
let reasoningAccumulated = '';
|
let reasoningAccumulated = '';
|
||||||
|
|
||||||
|
// v1.13.3: experimental_repairToolCall keeps the stream alive when the
|
||||||
|
// model emits a malformed tool call (bad JSON args, unknown name, etc.).
|
||||||
|
// Without a repair function streamText throws and the WHOLE stream dies;
|
||||||
|
// with one, the SDK invokes us and we route the bad call through normally.
|
||||||
|
// Strategy: pass through unmodified. executeToolPhase's existing error
|
||||||
|
// path (unknown tool name → "unknown tool: X" result; zod-reject → tool
|
||||||
|
// 'X' rejected — fieldname: required) already gives the model a clean
|
||||||
|
// recovery surface on the next turn. Logging gives us visibility into
|
||||||
|
// how often qwen3.6 actually emits broken calls.
|
||||||
|
const repairToolCall: ToolCallRepairFunction<NonNullable<typeof aiTools>> = async ({
|
||||||
|
toolCall,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
ctx.log.warn(
|
||||||
|
{
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
'malformed tool call surfaced via repairToolCall',
|
||||||
|
);
|
||||||
|
return toolCall;
|
||||||
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools ? { tools: aiTools, toolChoice: 'auto' as const } : {}),
|
...(aiTools
|
||||||
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
|
: {}),
|
||||||
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -527,8 +626,14 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
|
||||||
|
// cache hits on byte-identical prefixes; the tool list lives near the top
|
||||||
|
// of the system prompt, so any order drift would invalidate every cached
|
||||||
|
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
||||||
|
// 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>,
|
||||||
@@ -553,7 +658,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
watchChanges as ToolDef<unknown>,
|
watchChanges as ToolDef<unknown>,
|
||||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||||
getFrameworkAnalysis as ToolDef<unknown>,
|
getFrameworkAnalysis as ToolDef<unknown>,
|
||||||
];
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
// fully contained in this set gets a generous default tool budget (30);
|
// fully contained in this set gets a generous default tool budget (30);
|
||||||
@@ -565,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