Compare commits
4 Commits
v1.13.1-cl
...
v1.13.5-st
| Author | SHA1 | Date | |
|---|---|---|---|
| ff29b48e3a | |||
| 81d837c04e | |||
| f8fc5db929 | |||
| ec8593cf77 |
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.
|
||||
|
||||
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.
|
||||
- **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/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/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/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). 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/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 * as compaction from './services/compaction.js';
|
||||
import { configureModelContext } from './services/model-context.js';
|
||||
import { cleanupTruncations } from './services/truncate.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -238,7 +239,13 @@ async function main() {
|
||||
app.log.error({ err }, 'stuck-row sweeper failed');
|
||||
}
|
||||
};
|
||||
const sweepTimer = setInterval(() => { void sweepStaleStreaming(); }, SWEEP_INTERVAL_MS);
|
||||
// v1.13.5: truncation cleanup rides the same cadence — 60s tick reaps
|
||||
// tmpfs files past the 7-day TTL plus any orphans whose owning part has
|
||||
// been pruned (v1.13.4) or deleted. No-op when the dir is empty.
|
||||
const sweepTimer = setInterval(() => {
|
||||
void sweepStaleStreaming();
|
||||
void cleanupTruncations({ sql, log: app.log });
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
|
||||
@@ -56,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);
|
||||
|
||||
-- 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
|
||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||
@@ -73,23 +91,32 @@ SELECT
|
||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||
m.summary, m.tail_start_id, m.compacted_at,
|
||||
COALESCE(
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call'),
|
||||
m.tool_calls
|
||||
) AS tool_calls,
|
||||
COALESCE(
|
||||
(SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result'
|
||||
ORDER BY p.sequence
|
||||
LIMIT 1),
|
||||
m.tool_results
|
||||
) AS tool_results,
|
||||
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
||||
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
||||
-- (prune intended — return null/empty so the row drops from the model
|
||||
-- payload). A naive COALESCE would fall back to the legacy column when
|
||||
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
||||
-- splits the two cases.
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
||||
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
||||
ELSE m.tool_calls
|
||||
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)
|
||||
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;
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
turns,
|
||||
select,
|
||||
buildPrompt,
|
||||
buildHeadPayload,
|
||||
type CompactionMessage,
|
||||
} from '../compaction.js';
|
||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||
@@ -31,6 +32,7 @@ function mkMsg(
|
||||
status: 'complete',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
reasoning_parts: null,
|
||||
metadata: null,
|
||||
created_at: new Date(counter * 1000).toISOString(),
|
||||
...overrides,
|
||||
@@ -256,3 +258,56 @@ describe('buildPrompt', () => {
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
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.
|
||||
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { truncateIfNeeded } from './truncate.js';
|
||||
|
||||
export interface CodecontextRequest {
|
||||
toolName: string;
|
||||
@@ -27,6 +28,9 @@ export interface CodecontextRequest {
|
||||
export interface CodecontextResponse {
|
||||
result: string;
|
||||
truncated: boolean;
|
||||
// v1.13.5: optional opaque id pointing at the full pre-slice content on
|
||||
// tmpfs. Set when truncated=true and storage succeeded.
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080';
|
||||
@@ -105,13 +109,22 @@ export async function callCodecontext(
|
||||
|
||||
// Step 4: inline truncation. The model gets a clear hint about how to
|
||||
// narrow the next call rather than a silent cut. Mirrors web_fetch.ts.
|
||||
// v1.13.5: stash the full body on tmpfs when truncating so the model can
|
||||
// retrieve more via view_truncated_output(id).
|
||||
if (body.result.length > TRUNCATION_LIMIT) {
|
||||
const truncated = body.result.slice(0, TRUNCATION_LIMIT);
|
||||
const omitted = body.result.length - TRUNCATION_LIMIT;
|
||||
const slicedWithMarker =
|
||||
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`;
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: body.result,
|
||||
slicedContent: slicedWithMarker,
|
||||
wasTruncated: true,
|
||||
});
|
||||
return {
|
||||
result:
|
||||
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`,
|
||||
truncated: true,
|
||||
result: wrapped.content,
|
||||
truncated: wrapped.truncated,
|
||||
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||
};
|
||||
}
|
||||
return { result: body.result, truncated: false };
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface CompactionMessage {
|
||||
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
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;
|
||||
// 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;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -197,7 +202,8 @@ export function buildPrompt(
|
||||
// would silently drop pre-legacy-compact history before the LLM sees it.
|
||||
// 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';
|
||||
content: string | null;
|
||||
tool_calls?: Array<{
|
||||
@@ -212,7 +218,8 @@ function isCapHitSentinel(m: CompactionMessage): boolean {
|
||||
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[] = [];
|
||||
for (const m of head) {
|
||||
if (isCapHitSentinel(m)) continue;
|
||||
@@ -243,9 +250,22 @@ function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||
continue;
|
||||
}
|
||||
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 = {
|
||||
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) {
|
||||
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.
|
||||
// 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.
|
||||
// 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[]>`
|
||||
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
|
||||
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
||||
ORDER BY created_at ASC, id ASC
|
||||
|
||||
@@ -5,10 +5,15 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
||||
// - Agent with explicit max_tool_calls: that value.
|
||||
// - Agent with read-only-only tools: BUDGET_READ_ONLY (30).
|
||||
// - Agent with any non-read-only tool: BUDGET_NON_READ_ONLY (10).
|
||||
// - No agent (raw chat): BUDGET_NO_AGENT (15).
|
||||
// - No agent (raw chat): BUDGET_NO_AGENT (30).
|
||||
// v1.13.7: bumped BUDGET_NO_AGENT 15→30 to match BUDGET_READ_ONLY. Every tool
|
||||
// in ALL_TOOLS today is read-only (see services/tools.ts comment at
|
||||
// READ_ONLY_TOOL_NAMES); the cautious 15-cap was a forward-looking guard for
|
||||
// write tools that haven't landed yet. No-agent mode gets the same toolset as
|
||||
// an all-read-only agent at runtime, so they should share the same budget.
|
||||
export const BUDGET_READ_ONLY = 30;
|
||||
export const BUDGET_NON_READ_ONLY = 10;
|
||||
export const BUDGET_NO_AGENT = 15;
|
||||
export const BUDGET_NO_AGENT = 30;
|
||||
|
||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import * as compaction from '../compaction.js';
|
||||
import { buildSystemPrompt } from '../system-prompt.js';
|
||||
import { isAnySentinel } from './sentinels.js';
|
||||
import { PRUNE_TRIGGER_TOKENS, prune } from './prune.js';
|
||||
import type { InferenceContext } from './turn.js';
|
||||
|
||||
export interface OpenAiMessage {
|
||||
@@ -62,6 +63,25 @@ export async function buildMessagesPayload(
|
||||
if (isAnySentinel(m)) continue;
|
||||
if (m.role === 'assistant' && m.status === 'streaming') continue;
|
||||
if (m.role === 'assistant' && m.status === 'cancelled') continue;
|
||||
// v1.13.7: skip failed assistant turns. A failed row carries no usable
|
||||
// content for the model, and leaving it in the payload alongside any
|
||||
// following assistant message produces "Cannot have 2 or more assistant
|
||||
// messages at the end of the list" from the OpenAI-compatible upstream.
|
||||
if (m.role === 'assistant' && m.status === 'failed') continue;
|
||||
// v1.13.7: skip "empty" completed assistants — clen=0 + no tool_calls.
|
||||
// These can land when an upstream stream returns finishReason='stop' with
|
||||
// no text/tool output (network blip, rate limit recovery, model quirk).
|
||||
// Same risk as the failed-status case: a trailing empty assistant plus
|
||||
// the next attempt's assistant placeholder = two trailing assistants and
|
||||
// the API rejects the whole payload.
|
||||
if (
|
||||
m.role === 'assistant' &&
|
||||
m.status === 'complete' &&
|
||||
(m.content == null || m.content.trim().length === 0) &&
|
||||
(m.tool_calls == null || m.tool_calls.length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
const tr = m.tool_results;
|
||||
if (!tr) continue;
|
||||
@@ -166,6 +186,26 @@ export async function maybeFlagForCompaction(
|
||||
contextLimit,
|
||||
);
|
||||
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}`;
|
||||
ctx.log.info({ chatId, promptTokens, completionTokens, contextLimit }, 'inference: flagged for compaction');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible>
|
||||
provider = createOpenAICompatible({
|
||||
name: 'llama-swap',
|
||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
||||
// omits `stream_options.include_usage` from the request body. Without
|
||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
||||
// true here re-enables the per-stream usage payload across all models
|
||||
// served via the llama-swap provider.
|
||||
includeUsage: true,
|
||||
});
|
||||
cache.set(baseURL, provider);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getGitMeta } from './git_meta.js';
|
||||
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||
import { webSearch } from './web_search.js';
|
||||
import { webFetch } from './web_fetch.js';
|
||||
import { readTruncation, truncateIfNeeded } from './truncate.js';
|
||||
// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from
|
||||
// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts
|
||||
// which talks to the codecontext sidecar at http://codecontext:8080.
|
||||
@@ -109,12 +110,22 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
||||
const slice = lines.slice(start - 1, end);
|
||||
const content = slice.join('\n');
|
||||
const truncated = total > end || start > 1;
|
||||
// v1.13.5: stash the full file on tmpfs so the model can retrieve more
|
||||
// via view_truncated_output(id) without re-reading the file (which it
|
||||
// may not have project-relative-path access to in future agent setups).
|
||||
// raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap.
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: raw,
|
||||
slicedContent: content,
|
||||
wasTruncated: truncated,
|
||||
});
|
||||
return {
|
||||
path: relative(projectRoot, real) || basename(real),
|
||||
content,
|
||||
content: wrapped.content,
|
||||
total_lines: total,
|
||||
returned_lines: [start, end],
|
||||
truncated,
|
||||
truncated: wrapped.truncated,
|
||||
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -157,41 +168,64 @@ export const listDir: ToolDef<ListDirInputT> = {
|
||||
? entries
|
||||
: entries.filter((e) => !e.name.startsWith('.'));
|
||||
const total = filtered.length;
|
||||
const slice = filtered.slice(0, MAX_DIR_ENTRIES);
|
||||
const out = await Promise.all(
|
||||
slice.map(async (e) => {
|
||||
const child = resolve(real, e.name);
|
||||
let size: number | undefined;
|
||||
if (e.isFile()) {
|
||||
try {
|
||||
const cs = await stat(child);
|
||||
size = cs.size;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
||||
...(size != null ? { size } : {}),
|
||||
};
|
||||
})
|
||||
);
|
||||
// v1.11.7: filter entries whose project-relative path matches a secret
|
||||
// pattern. Each entry is tested using the project-rel dir + its name
|
||||
// so the pattern's path/segment semantics work for nested dirs like
|
||||
// `.aws/`. The count is surfaced via `pathguard_note` — we never list
|
||||
// the hidden paths (defeats the purpose).
|
||||
const wasTruncated = total > MAX_DIR_ENTRIES;
|
||||
const relDir = relative(projectRoot, real) || '.';
|
||||
// v1.13.5: when we'd truncate, render the FULL list to tmpfs so
|
||||
// view_truncated_output can serve it. Stat sizes for all entries when
|
||||
// truncating so the stored view matches the visible shape; this is the
|
||||
// one extra cost for big directories, bounded by total entries (which
|
||||
// is itself bounded by filesystem behavior).
|
||||
const processOne = async (e: typeof filtered[number]) => {
|
||||
const child = resolve(real, e.name);
|
||||
let size: number | undefined;
|
||||
if (e.isFile()) {
|
||||
try {
|
||||
const cs = await stat(child);
|
||||
size = cs.size;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
||||
...(size != null ? { size } : {}),
|
||||
};
|
||||
};
|
||||
const slice = filtered.slice(0, MAX_DIR_ENTRIES);
|
||||
const out = await Promise.all(slice.map(processOne));
|
||||
// v1.11.7: filter entries whose project-relative path matches a secret
|
||||
// pattern. The same filter applies to the full-list snapshot below so
|
||||
// the stashed file never holds entries the slice would have hidden.
|
||||
const secretFilter = filterSecretEntries(out, (e) =>
|
||||
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
||||
);
|
||||
let outputPath: string | undefined;
|
||||
if (wasTruncated) {
|
||||
const fullProcessed = await Promise.all(filtered.map(processOne));
|
||||
const fullFiltered = filterSecretEntries(fullProcessed, (e) =>
|
||||
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
||||
);
|
||||
// One line per entry, view_truncated_output's line slicing semantics
|
||||
// map cleanly. Format: "<type>\t<name>[\tsize=N]". Header documents
|
||||
// the shape so the model can grep / regex without prior schema lookup.
|
||||
const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`;
|
||||
const lines = [header, ...fullFiltered.kept.map((e) => {
|
||||
const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : '';
|
||||
return `${e.type}\t${e.name}${sz}`;
|
||||
})];
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: lines.join('\n'),
|
||||
slicedContent: '',
|
||||
wasTruncated: true,
|
||||
});
|
||||
outputPath = wrapped.outputPath;
|
||||
}
|
||||
return {
|
||||
path: relDir,
|
||||
entries: secretFilter.kept,
|
||||
total: secretFilter.kept.length,
|
||||
truncated: total > MAX_DIR_ENTRIES,
|
||||
truncated: wasTruncated,
|
||||
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -315,6 +349,71 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
},
|
||||
};
|
||||
|
||||
// v1.13.5: retrieves the full content of a previously-truncated tool output
|
||||
// via the opaque id stamped on the original tool_result. Line-based slicing
|
||||
// matches view_file's mental model so the model uses the same affordances.
|
||||
// Tmpfs-backed, 7-day TTL (see services/truncate.ts).
|
||||
const VIEW_TRUNCATED_DEFAULT_LINES = 200;
|
||||
|
||||
const ViewTruncatedOutputInput = z.object({
|
||||
id: z.string().regex(/^tr_[0-9a-v]{12}$/),
|
||||
start_line: z.number().int().positive().optional(),
|
||||
end_line: z.number().int().positive().optional(),
|
||||
});
|
||||
type ViewTruncatedOutputInputT = z.infer<typeof ViewTruncatedOutputInput>;
|
||||
|
||||
export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
||||
name: 'view_truncated_output',
|
||||
description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`,
|
||||
inputSchema: ViewTruncatedOutputInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'view_truncated_output',
|
||||
description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' },
|
||||
start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' },
|
||||
end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` },
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, _projectRoot) {
|
||||
const content = await readTruncation(input.id);
|
||||
if (content === null) {
|
||||
return {
|
||||
id: input.id,
|
||||
content: '',
|
||||
truncated: false,
|
||||
error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`,
|
||||
};
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const total = lines.length;
|
||||
let start = input.start_line ?? 1;
|
||||
let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1);
|
||||
if (start < 1) start = 1;
|
||||
if (end > total) end = total;
|
||||
if (end < start) end = start;
|
||||
const slice = lines.slice(start - 1, end).join('\n');
|
||||
// Re-slicing this view isn't truncation in the dual-write sense — the
|
||||
// model already has the id; no point stashing the slice again.
|
||||
const truncated = total > end || start > 1;
|
||||
return {
|
||||
id: input.id,
|
||||
content: slice,
|
||||
total_lines: total,
|
||||
returned_lines: [start, end],
|
||||
truncated,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// v1.8 Level 1 branch awareness: gives the model a read-only view of the
|
||||
// project's git state. No path input — operates on the inference-resolved
|
||||
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
|
||||
@@ -534,6 +633,7 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
||||
// and TOOLS_BY_NAME inherit it.
|
||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
viewFile as ToolDef<unknown>,
|
||||
viewTruncatedOutput as ToolDef<unknown>,
|
||||
listDir as ToolDef<unknown>,
|
||||
grep as ToolDef<unknown>,
|
||||
findFiles as ToolDef<unknown>,
|
||||
@@ -570,6 +670,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
// project state, so it belongs in the read-only set for budget purposes.
|
||||
export const READ_ONLY_TOOL_NAMES = [
|
||||
'view_file',
|
||||
'view_truncated_output',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
|
||||
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 { isPublicUrl } from './url_guard.js';
|
||||
import type { ToolDef } from './tools.js';
|
||||
import { truncateIfNeeded } from './truncate.js';
|
||||
|
||||
const WebFetchInput = z.object({
|
||||
url: z.string().min(1).max(2048),
|
||||
@@ -230,15 +231,24 @@ export async function executeWebFetch(
|
||||
}
|
||||
|
||||
const truncated = truncate(textRaw, maxChars);
|
||||
// v1.13.5: stash the full pre-slice body when truncation fires so the
|
||||
// model can pull more via view_truncated_output(id) without re-fetching.
|
||||
// textRaw is already bounded by MAX_BYTES (5MB), within truncate.ts's cap.
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: textRaw,
|
||||
slicedContent: truncated.content,
|
||||
wasTruncated: truncated.truncated,
|
||||
});
|
||||
// Report the FINAL URL (post-redirects) so the LLM knows where the body
|
||||
// came from — useful for citations and for the model to reason about
|
||||
// domain trust.
|
||||
return {
|
||||
url: currentUrl,
|
||||
title,
|
||||
content: truncated.content,
|
||||
content: wrapped.content,
|
||||
content_type: contentType,
|
||||
truncated: truncated.truncated,
|
||||
truncated: wrapped.truncated,
|
||||
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -651,7 +651,9 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
const hasContent = message.content.length > 0;
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
||||
// generic "message failed" line. Keeps the user's eye where it already is
|
||||
// rather than introducing a separate banner.
|
||||
|
||||
@@ -45,7 +45,12 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
|
||||
const hasText = m.content.length > 0;
|
||||
// v1.13.7: trim before checking. AI SDK v6 streaming occasionally emits a
|
||||
// leading "\n" text-delta on tool-call-only turns, which used to flow into
|
||||
// messages.content with length=1 and render an empty bubble + ActionRow
|
||||
// between each tool call. Whitespace-only content has no visible payload,
|
||||
// so treat it as no-content.
|
||||
const hasText = m.content.trim().length > 0;
|
||||
if (m.role === 'assistant' && hasToolCalls) {
|
||||
if (hasText || m.status === 'streaming') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
|
||||
Reference in New Issue
Block a user