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:
parent
64e040ebfe
commit
0d9c473a06
9 changed files with 104 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(' ')}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue