feat(session-anchors): configurable budget scale + research-backed truncation fix

Remove 500-char assistant text truncation in anchor serializer — research
consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC) is that agent
reasoning must never be truncated; only tool outputs get observation-masked.

Add AnchorBudgetScale type with 4 presets (Small=2K, Medium=6K, Large=12K,
Full=20K) and per-project range slider in SettingsTab. Remove Ollama-specific
warning toast — budget slider handles context limits generically.
This commit is contained in:
Hibryda 2026-03-11 03:03:53 +01:00
parent 64e040ebfe
commit 0d9c473a06
9 changed files with 104 additions and 23 deletions

View file

@ -31,6 +31,7 @@ import { extractWritePaths, extractWorktreePath } from './utils/tool-files';
import { hasAutoAnchored, markAutoAnchored, addAnchors, getAnchorSettings } from './stores/anchors.svelte';
import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer';
import type { SessionAnchor } from './types/anchors';
import { getEnabledProjects } from './stores/workspace.svelte';
let unlistenMsg: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
@ -418,7 +419,8 @@ function triggerAutoAnchor(
): void {
markAutoAnchored(projectId);
const settings = getAnchorSettings(projectId);
const project = getEnabledProjects().find(p => p.id === projectId);
const settings = getAnchorSettings(project?.anchorBudgetScale);
const { turns, totalTokens } = selectAutoAnchors(
messages,
sessionPrompt,

View file

@ -14,7 +14,7 @@
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
import { estimateTokens } from '../../utils/anchor-serializer';
import type { SessionAnchor } from '../../types/anchors';
import { notify } from '../../stores/notifications.svelte';
import AgentTree from './AgentTree.svelte';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
import type {
@ -171,12 +171,6 @@
if (anchors.length > 0) {
// Anchors store pre-serialized content — join them directly
systemPrompt = anchors.map(a => a.content).join('\n');
// Warn if Ollama provider — default context windows (2K-4K) may be too small
if (providerId === 'ollama') {
const anchorTokens = anchors.reduce((sum, a) => sum + a.estimatedTokens, 0);
notify('warning', `Ollama: injecting ~${anchorTokens} anchor tokens into system prompt. Ensure num_ctx >= 8192 to avoid truncation.`);
}
}
}

View file

@ -9,15 +9,16 @@
removeAnchor,
changeAnchorType,
} from '../../stores/anchors.svelte';
import { DEFAULT_ANCHOR_SETTINGS, MAX_ANCHOR_TOKEN_BUDGET } from '../../types/anchors';
import type { SessionAnchor, AnchorType } from '../../types/anchors';
import { ANCHOR_BUDGET_SCALE_MAP, MAX_ANCHOR_TOKEN_BUDGET } from '../../types/anchors';
import type { SessionAnchor, AnchorType, AnchorBudgetScale } from '../../types/anchors';
interface Props {
sessionId: string | null;
projectId?: string;
anchorBudgetScale?: AnchorBudgetScale;
}
let { sessionId, projectId }: Props = $props();
let { sessionId, projectId, anchorBudgetScale }: Props = $props();
// Reactive session data
let session = $derived(sessionId ? getAgentSession(sessionId) : undefined);
@ -193,7 +194,7 @@
let anchors = $derived(projectId ? getProjectAnchors(projectId) : []);
let injectableAnchors = $derived(projectId ? getInjectableAnchors(projectId) : []);
let anchorTokens = $derived(projectId ? getInjectableTokenCount(projectId) : 0);
let anchorBudget = $derived(DEFAULT_ANCHOR_SETTINGS.anchorTokenBudget);
let anchorBudget = $derived(ANCHOR_BUDGET_SCALE_MAP[anchorBudgetScale ?? 'medium']);
let anchorBudgetPct = $derived(anchorBudget > 0 ? Math.min((anchorTokens / anchorBudget) * 100, 100) : 0);
function anchorTypeLabel(t: AnchorType): string {

View file

@ -157,7 +157,7 @@
<ProjectFiles cwd={project.cwd} projectName={project.name} />
</div>
<div class="content-pane" style:display={activeTab === 'context' ? 'flex' : 'none'}>
<ContextTab sessionId={mainSessionId} projectId={project.id} />
<ContextTab sessionId={mainSessionId} projectId={project.id} anchorBudgetScale={project.anchorBudgetScale} />
</div>
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->

View file

@ -20,6 +20,7 @@
import { invoke } from '@tauri-apps/api/core';
import { getProviders } from '../../providers/registry.svelte';
import type { ProviderId, ProviderSettings } from '../../providers/types';
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
const PROJECT_ICONS = [
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
@ -771,6 +772,27 @@
</div>
{/if}
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
Anchor Budget
</span>
<div class="scale-slider">
<input
type="range"
min="0"
max={ANCHOR_BUDGET_SCALES.length - 1}
step="1"
value={ANCHOR_BUDGET_SCALES.indexOf(project.anchorBudgetScale ?? 'medium')}
oninput={(e) => {
const idx = parseInt((e.target as HTMLInputElement).value);
updateProject(activeGroupId, project.id, { anchorBudgetScale: ANCHOR_BUDGET_SCALES[idx] });
}}
/>
<span class="scale-label">{ANCHOR_BUDGET_SCALE_LABELS[project.anchorBudgetScale ?? 'medium']}</span>
</div>
</div>
<div class="card-footer">
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
@ -1203,6 +1225,40 @@
font-family: var(--term-font-family, monospace);
}
/* Anchor budget scale slider */
.scale-slider {
display: flex;
align-items: center;
gap: 0.5rem;
}
.scale-slider input[type="range"] {
flex: 1;
height: 0.25rem;
appearance: none;
background: var(--ctp-surface1);
border-radius: 0.125rem;
outline: none;
cursor: pointer;
}
.scale-slider input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
background: var(--ctp-blue);
border: 2px solid var(--ctp-base);
cursor: pointer;
}
.scale-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
white-space: nowrap;
min-width: 5.5em;
}
/* CWD input: left-ellipsis */
.cwd-input {
direction: rtl;

View file

@ -1,8 +1,8 @@
// Session Anchors store — Svelte 5 runes
// Per-project anchor management with re-injection support
import type { SessionAnchor, AnchorType, SessionAnchorRecord } from '../types/anchors';
import { DEFAULT_ANCHOR_SETTINGS } from '../types/anchors';
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
import {
saveSessionAnchors,
loadSessionAnchors,
@ -119,7 +119,11 @@ export async function loadAnchorsForProject(projectId: string): Promise<void> {
}
}
/** Get anchor settings (uses defaults for now — per-project config can be added later) */
export function getAnchorSettings(_projectId: string) {
return DEFAULT_ANCHOR_SETTINGS;
/** Get anchor settings, resolving budget from per-project scale if provided */
export function getAnchorSettings(budgetScale?: AnchorBudgetScale) {
if (!budgetScale) return DEFAULT_ANCHOR_SETTINGS;
return {
...DEFAULT_ANCHOR_SETTINGS,
anchorTokenBudget: ANCHOR_BUDGET_SCALE_MAP[budgetScale],
};
}

View file

@ -37,6 +37,28 @@ export const MAX_ANCHOR_TOKEN_BUDGET = 20_000;
/** Minimum token budget */
export const MIN_ANCHOR_TOKEN_BUDGET = 2_000;
/** Budget scale presets — maps to provider context window sizes */
export type AnchorBudgetScale = 'small' | 'medium' | 'large' | 'full';
/** Token budget for each scale preset */
export const ANCHOR_BUDGET_SCALE_MAP: Record<AnchorBudgetScale, number> = {
small: 2_000,
medium: 6_144,
large: 12_000,
full: 20_000,
};
/** Human-readable labels for budget scale presets */
export const ANCHOR_BUDGET_SCALE_LABELS: Record<AnchorBudgetScale, string> = {
small: 'Small (2K)',
medium: 'Medium (6K)',
large: 'Large (12K)',
full: 'Full (20K)',
};
/** Ordered list of scales for slider indexing */
export const ANCHOR_BUDGET_SCALES: AnchorBudgetScale[] = ['small', 'medium', 'large', 'full'];
/** Rust-side record shape (matches SessionAnchorRecord in session.rs) */
export interface SessionAnchorRecord {
id: string;

View file

@ -1,4 +1,5 @@
import type { ProviderId } from '../providers/types';
import type { AnchorBudgetScale } from './anchors';
export interface ProjectConfig {
id: string;
@ -13,6 +14,8 @@ export interface ProjectConfig {
provider?: ProviderId;
/** When true, agents for this project use git worktrees for isolation */
useWorktrees?: boolean;
/** Anchor token budget scale (defaults to 'medium' = 6K tokens) */
anchorBudgetScale?: AnchorBudgetScale;
}
export interface GroupConfig {

View file

@ -137,11 +137,10 @@ function serializeTurn(
parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`);
}
if (turn.assistantText) {
// Truncate very long responses to ~500 chars
const text = turn.assistantText.length > 500
? turn.assistantText.slice(0, 497) + '...'
: turn.assistantText;
parts.push(`[Turn ${index + 1}] Assistant: "${text}"`);
// Preserve assistant reasoning in full — research consensus (JetBrains NeurIPS 2025,
// SWE-agent, OpenDev ACC) is that agent reasoning must never be truncated;
// only tool outputs (observations) get masked
parts.push(`[Turn ${index + 1}] Assistant: "${turn.assistantText}"`);
}
if (turn.toolSummaries.length > 0) {
parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`);