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 { hasAutoAnchored, markAutoAnchored, addAnchors, getAnchorSettings } from './stores/anchors.svelte';
|
||||||
import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer';
|
import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer';
|
||||||
import type { SessionAnchor } from './types/anchors';
|
import type { SessionAnchor } from './types/anchors';
|
||||||
|
import { getEnabledProjects } from './stores/workspace.svelte';
|
||||||
|
|
||||||
let unlistenMsg: (() => void) | null = null;
|
let unlistenMsg: (() => void) | null = null;
|
||||||
let unlistenExit: (() => void) | null = null;
|
let unlistenExit: (() => void) | null = null;
|
||||||
|
|
@ -418,7 +419,8 @@ function triggerAutoAnchor(
|
||||||
): void {
|
): void {
|
||||||
markAutoAnchored(projectId);
|
markAutoAnchored(projectId);
|
||||||
|
|
||||||
const settings = getAnchorSettings(projectId);
|
const project = getEnabledProjects().find(p => p.id === projectId);
|
||||||
|
const settings = getAnchorSettings(project?.anchorBudgetScale);
|
||||||
const { turns, totalTokens } = selectAutoAnchors(
|
const { turns, totalTokens } = selectAutoAnchors(
|
||||||
messages,
|
messages,
|
||||||
sessionPrompt,
|
sessionPrompt,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
||||||
import { estimateTokens } from '../../utils/anchor-serializer';
|
import { estimateTokens } from '../../utils/anchor-serializer';
|
||||||
import type { SessionAnchor } from '../../types/anchors';
|
import type { SessionAnchor } from '../../types/anchors';
|
||||||
import { notify } from '../../stores/notifications.svelte';
|
|
||||||
import AgentTree from './AgentTree.svelte';
|
import AgentTree from './AgentTree.svelte';
|
||||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -171,12 +171,6 @@
|
||||||
if (anchors.length > 0) {
|
if (anchors.length > 0) {
|
||||||
// Anchors store pre-serialized content — join them directly
|
// Anchors store pre-serialized content — join them directly
|
||||||
systemPrompt = anchors.map(a => a.content).join('\n');
|
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,
|
removeAnchor,
|
||||||
changeAnchorType,
|
changeAnchorType,
|
||||||
} from '../../stores/anchors.svelte';
|
} from '../../stores/anchors.svelte';
|
||||||
import { DEFAULT_ANCHOR_SETTINGS, MAX_ANCHOR_TOKEN_BUDGET } from '../../types/anchors';
|
import { ANCHOR_BUDGET_SCALE_MAP, MAX_ANCHOR_TOKEN_BUDGET } from '../../types/anchors';
|
||||||
import type { SessionAnchor, AnchorType } from '../../types/anchors';
|
import type { SessionAnchor, AnchorType, AnchorBudgetScale } from '../../types/anchors';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
anchorBudgetScale?: AnchorBudgetScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { sessionId, projectId }: Props = $props();
|
let { sessionId, projectId, anchorBudgetScale }: Props = $props();
|
||||||
|
|
||||||
// Reactive session data
|
// Reactive session data
|
||||||
let session = $derived(sessionId ? getAgentSession(sessionId) : undefined);
|
let session = $derived(sessionId ? getAgentSession(sessionId) : undefined);
|
||||||
|
|
@ -193,7 +194,7 @@
|
||||||
let anchors = $derived(projectId ? getProjectAnchors(projectId) : []);
|
let anchors = $derived(projectId ? getProjectAnchors(projectId) : []);
|
||||||
let injectableAnchors = $derived(projectId ? getInjectableAnchors(projectId) : []);
|
let injectableAnchors = $derived(projectId ? getInjectableAnchors(projectId) : []);
|
||||||
let anchorTokens = $derived(projectId ? getInjectableTokenCount(projectId) : 0);
|
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);
|
let anchorBudgetPct = $derived(anchorBudget > 0 ? Math.min((anchorTokens / anchorBudget) * 100, 100) : 0);
|
||||||
|
|
||||||
function anchorTypeLabel(t: AnchorType): string {
|
function anchorTypeLabel(t: AnchorType): string {
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
<ProjectFiles cwd={project.cwd} projectName={project.name} />
|
<ProjectFiles cwd={project.cwd} projectName={project.name} />
|
||||||
</div>
|
</div>
|
||||||
<div class="content-pane" style:display={activeTab === 'context' ? 'flex' : 'none'}>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->
|
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getProviders } from '../../providers/registry.svelte';
|
import { getProviders } from '../../providers/registry.svelte';
|
||||||
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
||||||
|
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
|
||||||
|
|
||||||
const PROJECT_ICONS = [
|
const PROJECT_ICONS = [
|
||||||
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
|
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
|
||||||
|
|
@ -771,6 +772,27 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="card-footer">
|
||||||
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
|
<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>
|
<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);
|
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: left-ellipsis */
|
||||||
.cwd-input {
|
.cwd-input {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Session Anchors store — Svelte 5 runes
|
// Session Anchors store — Svelte 5 runes
|
||||||
// Per-project anchor management with re-injection support
|
// Per-project anchor management with re-injection support
|
||||||
|
|
||||||
import type { SessionAnchor, AnchorType, SessionAnchorRecord } from '../types/anchors';
|
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
|
||||||
import { DEFAULT_ANCHOR_SETTINGS } from '../types/anchors';
|
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
|
||||||
import {
|
import {
|
||||||
saveSessionAnchors,
|
saveSessionAnchors,
|
||||||
loadSessionAnchors,
|
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) */
|
/** Get anchor settings, resolving budget from per-project scale if provided */
|
||||||
export function getAnchorSettings(_projectId: string) {
|
export function getAnchorSettings(budgetScale?: AnchorBudgetScale) {
|
||||||
return DEFAULT_ANCHOR_SETTINGS;
|
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 */
|
/** Minimum token budget */
|
||||||
export const MIN_ANCHOR_TOKEN_BUDGET = 2_000;
|
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) */
|
/** Rust-side record shape (matches SessionAnchorRecord in session.rs) */
|
||||||
export interface SessionAnchorRecord {
|
export interface SessionAnchorRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ProviderId } from '../providers/types';
|
import type { ProviderId } from '../providers/types';
|
||||||
|
import type { AnchorBudgetScale } from './anchors';
|
||||||
|
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -13,6 +14,8 @@ export interface ProjectConfig {
|
||||||
provider?: ProviderId;
|
provider?: ProviderId;
|
||||||
/** When true, agents for this project use git worktrees for isolation */
|
/** When true, agents for this project use git worktrees for isolation */
|
||||||
useWorktrees?: boolean;
|
useWorktrees?: boolean;
|
||||||
|
/** Anchor token budget scale (defaults to 'medium' = 6K tokens) */
|
||||||
|
anchorBudgetScale?: AnchorBudgetScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupConfig {
|
export interface GroupConfig {
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,10 @@ function serializeTurn(
|
||||||
parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`);
|
parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`);
|
||||||
}
|
}
|
||||||
if (turn.assistantText) {
|
if (turn.assistantText) {
|
||||||
// Truncate very long responses to ~500 chars
|
// Preserve assistant reasoning in full — research consensus (JetBrains NeurIPS 2025,
|
||||||
const text = turn.assistantText.length > 500
|
// SWE-agent, OpenDev ACC) is that agent reasoning must never be truncated;
|
||||||
? turn.assistantText.slice(0, 497) + '...'
|
// only tool outputs (observations) get masked
|
||||||
: turn.assistantText;
|
parts.push(`[Turn ${index + 1}] Assistant: "${turn.assistantText}"`);
|
||||||
parts.push(`[Turn ${index + 1}] Assistant: "${text}"`);
|
|
||||||
}
|
}
|
||||||
if (turn.toolSummaries.length > 0) {
|
if (turn.toolSummaries.length > 0) {
|
||||||
parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`);
|
parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue