Compare commits
2 Commits
v1.13.2-co
...
v1.13.4-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 81d837c04e | |||
| f8fc5db929 |
14
CLAUDE.md
14
CLAUDE.md
@@ -46,12 +46,20 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
|||||||
- **Zod** for request validation and config parsing.
|
- **Zod** for request validation and config parsing.
|
||||||
|
|
||||||
Key services:
|
Key services:
|
||||||
- **`services/inference/`** (v1.12.4 split — was a single `inference.ts` file). Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js`. Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner orchestration, plus `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult` exported), `stream-phase.ts` (streamCompletion + executeStreamPhase + SSE parsing), `tool-phase.ts` (executeToolPhase; back-edges into turn.ts for the runAssistantTurn recursion — cycle is safe because dereferenced at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters; two near-clones kept side-by-side until a third sentinel justifies factoring out runWrapUpSummary), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (Qwen-coder XML tool-call fallback), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS` shared between stream-phase and sentinel-summaries). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion (`toolsUsed`, `recentToolCalls`, `assistantMessageId`, `signal`); reset to defaults in `runInference` at the user-message boundary. Cap-hit (`toolsUsed >= budget`) and doom-loop (`detectDoomLoop(recentToolCalls)`) checks both read from this envelope. Add new per-turn state to `TurnArgs` in `turn.ts`, not module-level closures.
|
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
|
||||||
|
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Three gotchas the LSP/test suite won't catch:
|
||||||
|
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||||
|
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||||
|
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Only `description` + `inputSchema: jsonSchema(parameters)` — surfacing tool-call parts via `fullStream` and stopping is what we want.
|
||||||
|
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart` — BooCode's OpenAI-shape history doesn't carry it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` matching the v6 `ToolResultOutput` union. Assistant messages with reasoning emit a `ReasoningPart` first in the content array (v1.13.1-C).
|
||||||
|
- **`experimental_repairToolCall`** (v1.13.3) wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through implementation — logs the bad call and returns it unmodified; `executeToolPhase`'s existing zod-reject error path routes it to the model on the next turn.
|
||||||
- **`chat_status` frame shape** (published via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'` (widened from `working|idle|error` in v1.12.1). Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders inline beside `StatusDot` only when streaming or tool_running, fed by 500ms-throttled `'usage'` WS frames (`completion_tokens` + `ctx_used` + `ctx_max`). The `POST /api/chats/:id/discard_stale` endpoint exists to mark a stuck-streaming row as `failed` when the frontend's 60s no-token-activity timer (`ChatPane` content-length watcher) gives up.
|
- **`chat_status` frame shape** (published via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'` (widened from `working|idle|error` in v1.12.1). Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders inline beside `StatusDot` only when streaming or tool_running, fed by 500ms-throttled `'usage'` WS frames (`completion_tokens` + `ctx_used` + `ctx_max`). The `POST /api/chats/:id/discard_stale` endpoint exists to mark a stuck-streaming row as `failed` when the frontend's 60s no-token-activity timer (`ChatPane` content-length watcher) gives up.
|
||||||
- **Boot-time stale-streaming sweep** in `apps/server/src/index.ts` after `applySchema()`: any `messages.status='streaming'` older than 5 minutes flips to `'failed'`. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1).
|
- **Boot-time stale-streaming sweep** in `apps/server/src/index.ts` after `applySchema()`: any `messages.status='streaming'` older than 5 minutes flips to `'failed'`. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1).
|
||||||
|
- **Periodic 60s sweeper** in `apps/server/src/index.ts` (v1.13.3 + v1.13.5). Same `setInterval` runs `sweepStaleStreaming` (marks `messages.status='streaming'` older than 5 min as `failed`, publishes `chat_status='idle'` so the UI dot drops) and `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `app.addHook('onClose')` clears the timer. No-op when nothing to reap.
|
||||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
||||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false.
|
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false. v1.13.5 truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs at `BOOCODE_TRUNCATION_DIR` (default `/tmp/boocode-truncations`, 0o700) keyed by an opaque `tr_<12 base32 chars>` id, and the `view_truncated_output(id)` tool retrieves it. 5MB cap (matches `view_file`'s `MAX_FILE_BYTES`), 7-day TTL, reaped by the periodic sweeper. Tmpfs path means container restart loses retrieval — acceptable, the model usually has moved on.
|
||||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).
|
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
||||||
|
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
|
||||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
turns,
|
turns,
|
||||||
select,
|
select,
|
||||||
buildPrompt,
|
buildPrompt,
|
||||||
|
buildHeadPayload,
|
||||||
type CompactionMessage,
|
type CompactionMessage,
|
||||||
} from '../compaction.js';
|
} from '../compaction.js';
|
||||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||||
@@ -31,6 +32,7 @@ function mkMsg(
|
|||||||
status: 'complete',
|
status: 'complete',
|
||||||
tool_calls: null,
|
tool_calls: null,
|
||||||
tool_results: null,
|
tool_results: null,
|
||||||
|
reasoning_parts: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
created_at: new Date(counter * 1000).toISOString(),
|
created_at: new Date(counter * 1000).toISOString(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -256,3 +258,56 @@ describe('buildPrompt', () => {
|
|||||||
expect(out.endsWith('extra-context-line')).toBe(true);
|
expect(out.endsWith('extra-context-line')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- buildHeadPayload (v1.13.6) -----------------------------------------------
|
||||||
|
|
||||||
|
describe('buildHeadPayload reasoning render', () => {
|
||||||
|
it('emits reasoning as a <reasoning> tag prefixed onto the assistant content', () => {
|
||||||
|
const out = buildHeadPayload([
|
||||||
|
mkMsg('user', 'show me the file'),
|
||||||
|
mkMsg('assistant', 'reading it now', {
|
||||||
|
reasoning_parts: [{ text: 'user wants src/index.ts; I should view it' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out[1]!.role).toBe('assistant');
|
||||||
|
expect(out[1]!.content).toBe(
|
||||||
|
'<reasoning>user wants src/index.ts; I should view it</reasoning>\n\nreading it now',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a standalone <reasoning> tag when reasoning is present but content is empty (tool-call-only turn)', () => {
|
||||||
|
const out = buildHeadPayload([
|
||||||
|
mkMsg('assistant', '', {
|
||||||
|
reasoning_parts: [{ text: 'jumping straight to grep' }],
|
||||||
|
tool_calls: [{ id: 'c1', name: 'grep', args: { pattern: 'foo' } }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]!.content).toBe('<reasoning>jumping straight to grep</reasoning>');
|
||||||
|
expect(out[0]!.tool_calls).toHaveLength(1);
|
||||||
|
expect(out[0]!.tool_calls![0]!.function.name).toBe('grep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins multiple reasoning parts without separators (matches the streaming concat)', () => {
|
||||||
|
const out = buildHeadPayload([
|
||||||
|
mkMsg('assistant', 'final answer', {
|
||||||
|
reasoning_parts: [{ text: 'first thought ' }, { text: 'second thought' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(out[0]!.content).toBe(
|
||||||
|
'<reasoning>first thought second thought</reasoning>\n\nfinal answer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the reasoning tag entirely when reasoning_parts is null or empty', () => {
|
||||||
|
const out = buildHeadPayload([
|
||||||
|
mkMsg('assistant', 'plain answer', { reasoning_parts: null }),
|
||||||
|
mkMsg('assistant', 'other answer', { reasoning_parts: [] }),
|
||||||
|
]);
|
||||||
|
expect(out[0]!.content).toBe('plain answer');
|
||||||
|
expect(out[1]!.content).toBe('other answer');
|
||||||
|
expect(out[0]!.content).not.toContain('<reasoning>');
|
||||||
|
expect(out[1]!.content).not.toContain('<reasoning>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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 };
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export interface CompactionMessage {
|
|||||||
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||||
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
|
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
|
||||||
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
|
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
|
||||||
|
// v1.13.6: reasoning_parts captured by v1.13.1-C and read back through
|
||||||
|
// messages_with_parts. Embedded into the head-assembly payload as prose so
|
||||||
|
// the summarizer LLM sees what the model was reasoning through when it
|
||||||
|
// chose its tool calls.
|
||||||
|
reasoning_parts: Array<{ text: string }> | null;
|
||||||
metadata: { kind?: string } | null;
|
metadata: { kind?: string } | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -197,7 +202,8 @@ export function buildPrompt(
|
|||||||
// would silently drop pre-legacy-compact history before the LLM sees it.
|
// would silently drop pre-legacy-compact history before the LLM sees it.
|
||||||
// Compaction wants to send the entire head, full stop.) ===
|
// Compaction wants to send the entire head, full stop.) ===
|
||||||
|
|
||||||
interface OpenAiMessage {
|
// v1.13.6: exported for unit-test access (reasoning render coverage).
|
||||||
|
export interface OpenAiMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
content: string | null;
|
content: string | null;
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
@@ -212,7 +218,8 @@ function isCapHitSentinel(m: CompactionMessage): boolean {
|
|||||||
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
|
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
// v1.13.6: exported for unit-test access (reasoning render coverage).
|
||||||
|
export function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||||
const out: OpenAiMessage[] = [];
|
const out: OpenAiMessage[] = [];
|
||||||
for (const m of head) {
|
for (const m of head) {
|
||||||
if (isCapHitSentinel(m)) continue;
|
if (isCapHitSentinel(m)) continue;
|
||||||
@@ -243,9 +250,22 @@ function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (m.role === 'assistant') {
|
if (m.role === 'assistant') {
|
||||||
|
// v1.13.6: embed reasoning text as prose prefixed onto the assistant
|
||||||
|
// content. OpenAI wire shape doesn't carry reasoning as a structured
|
||||||
|
// field, but the summarizer is reading text — a tagged prose block
|
||||||
|
// gives it the same signal. We mirror the AI SDK ReasoningPart shape
|
||||||
|
// by using a <reasoning>...</reasoning> wrapper so the summarizer can
|
||||||
|
// distinguish reasoning from user-visible answer.
|
||||||
|
let body = m.content && m.content.length > 0 ? m.content : '';
|
||||||
|
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
||||||
|
const reasoning = m.reasoning_parts.map((r) => r.text).join('');
|
||||||
|
body = body.length > 0
|
||||||
|
? `<reasoning>${reasoning}</reasoning>\n\n${body}`
|
||||||
|
: `<reasoning>${reasoning}</reasoning>`;
|
||||||
|
}
|
||||||
const msg: OpenAiMessage = {
|
const msg: OpenAiMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: m.content && m.content.length > 0 ? m.content : null,
|
content: body.length > 0 ? body : null,
|
||||||
};
|
};
|
||||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||||
@@ -344,8 +364,11 @@ export async function process(input: ProcessInput): Promise<void> {
|
|||||||
// turns() boundary logic sees the same sequence the LLM will.
|
// turns() boundary logic sees the same sequence the LLM will.
|
||||||
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view so
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view so
|
||||||
// the compaction payload matches what the LLM saw on the original turn.
|
// the compaction payload matches what the LLM saw on the original turn.
|
||||||
|
// v1.13.6: also pulls reasoning_parts (added in v1.13.1-C) so summaries
|
||||||
|
// capture what the model was working through before each tool call.
|
||||||
const messages = await sql<CompactionMessage[]>`
|
const messages = await sql<CompactionMessage[]>`
|
||||||
SELECT id, role, content, kind, summary, status, tool_calls, tool_results, metadata, created_at
|
SELECT id, role, content, kind, summary, status, tool_calls, tool_results,
|
||||||
|
reasoning_parts, metadata, created_at
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -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