fix: compact large ai agent context

This commit is contained in:
lucaronin
2026-05-02 19:24:52 +02:00
parent e23ba0ae27
commit 6d86770abf
5 changed files with 217 additions and 65 deletions

View File

@@ -298,15 +298,15 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
```json
{
"activeNote": { "path", "title", "type", "frontmatter", "content" },
"linkedNotes": [{ "path", "title", "content" }],
"openTabs": [{ "title", "snippet" }],
"vaultMetadata": { "noteTypes", "stats", "filter" },
"references": [{ "title", "path", "type" }]
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
"openTabs": [{ "path", "title", "type", "frontmatter" }],
"noteList": [{ "path", "title", "type" }],
"vault": { "types", "totalNotes" },
"referencedNotes": [{ "title", "path", "type" }]
}
```
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
### Authentication

View File

@@ -219,6 +219,37 @@ describe('aiAgentStreamCallbacks', () => {
])
})
it('gives OpenCode an actionable empty-response message', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Summarize the current note',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
agent: 'opencode',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(messages.getMessages()[0].response).toContain('OpenCode returned no assistant text')
expect(messages.getMessages()[0].response).toContain('provider/model context limit')
expect(messages.getMessages()[0].response).not.toContain('finished without returning a reply')
})
it('ignores stream events after the request has been aborted', () => {
const messages = createMessageStore([
{

View File

@@ -26,9 +26,17 @@ export interface StreamMutationContext {
}
function finalResponseText(response: string, agent: AiAgentId): string {
return response.trim()
? response
: `${getAiAgentDefinition(agent).label} finished without returning a reply.`
if (response.trim()) return response
if (agent === 'opencode') {
return [
'OpenCode returned no assistant text.',
'Check the selected provider/model context limit or retry the request.',
'For large active notes, Tolaria sends a compact note snapshot and OpenCode can read the full file with get_note(path).',
].join(' ')
}
return `${getAiAgentDefinition(agent).label} finished without returning a reply.`
}
export function createStreamCallbacks(context: StreamMutationContext) {

View File

@@ -312,6 +312,34 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('Fresh editor content')
})
it('compacts large active note bodies and points agents at the full note tool', () => {
const largeBody = [
'Opening section '.repeat(900),
'Middle section that should not be fully embedded '.repeat(900),
'Closing section '.repeat(900),
].join('\n')
const result = buildContextSnapshot({
activeEntry: makeEntry({
path: '/vault/large-note.md',
title: 'Large Note',
wordCount: 2700,
}),
entries,
activeNoteContent: largeBody,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body.length).toBeLessThan(largeBody.length)
expect(json.activeNote.body).toContain('Opening section')
expect(json.activeNote.body).toContain('Closing section')
expect(json.activeNote.body).toContain('get_note("/vault/large-note.md")')
expect(json.activeNote.bodyTruncated).toEqual({
shownChars: expect.any(Number),
totalChars: largeBody.trim().length,
strategy: 'head-tail',
})
})
it('returns empty body when no activeNoteContent', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])

View File

@@ -81,92 +81,177 @@ export interface ContextSnapshotParams {
references?: NoteReference[]
}
const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000
const ACTIVE_NOTE_BODY_HEAD_CHARS = 16_000
const ACTIVE_NOTE_BODY_TAIL_CHARS = 4_000
const MAX_NOTE_LIST_ITEMS = 100
interface ActiveNoteBody {
body: string
bodyTruncated?: {
shownChars: number
totalChars: number
strategy: 'head-tail'
}
}
function isPresentValue(value: unknown): boolean {
if (value === null) return false
if (value === undefined) return false
if (value === '') return false
return true
}
function assignIfPresent(target: Record<string, unknown>, key: string, value: unknown): void {
if (isPresentValue(value)) target[key] = value
}
function assignIfNonEmpty(target: Record<string, unknown>, key: string, values: unknown[]): void {
if (values.length > 0) {
target[key] = values
}
}
function propertyString(value: unknown): string | undefined {
if (!isPresentValue(value)) return undefined
return typeof value === 'string' ? value : String(value)
}
function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
const fm: Record<string, unknown> = {}
if (e.isA) fm.type = e.isA
if (e.status) fm.status = e.status
// Owner and cadence are now stored in properties, not first-class fields
const owner = e.properties?.Owner ?? e.properties?.owner
const cadence = e.properties?.Cadence ?? e.properties?.cadence
if (owner) fm.owner = typeof owner === 'string' ? owner : String(owner)
if (cadence) fm.cadence = typeof cadence === 'string' ? cadence : String(cadence)
if (e.belongsTo.length > 0) fm.belongsTo = e.belongsTo
if (e.relatedTo.length > 0) fm.relatedTo = e.relatedTo
if (Object.keys(e.relationships).length > 0) fm.relationships = e.relationships
assignIfPresent(fm, 'type', e.isA)
assignIfPresent(fm, 'status', e.status)
assignIfPresent(fm, 'owner', propertyString(e.properties?.Owner ?? e.properties?.owner))
assignIfPresent(fm, 'cadence', propertyString(e.properties?.Cadence ?? e.properties?.cadence))
assignIfNonEmpty(fm, 'belongsTo', e.belongsTo)
assignIfNonEmpty(fm, 'relatedTo', e.relatedTo)
assignIfNonEmpty(fm, 'relationships', Object.keys(e.relationships))
if (fm.relationships) fm.relationships = e.relationships
return fm
}
const MAX_NOTE_LIST_ITEMS = 100
function unavailableBodyInstruction(activeEntry: VaultEntry): string {
return `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
}
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
function truncatedBodyInstruction(path: string, omittedChars: number): string {
return [
'[Active note body truncated by Tolaria to keep CLI agent context within provider limits.',
`Omitted approximately ${omittedChars} characters from the middle.`,
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
].join(' ')
}
const rawContent = activeNoteContent || ''
let body = extractBody(rawContent)
// Defence-in-depth: when body is empty but the note has content on disk,
// include an explicit instruction in the body field itself (more reliable
// than a preamble instruction that Claude might skip).
if (!body && activeEntry.wordCount > 0) {
body = `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) {
return { body }
}
const snapshot: Record<string, unknown> = {
activeNote: {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body,
wordCount: activeEntry.wordCount,
const head = body.slice(0, ACTIVE_NOTE_BODY_HEAD_CHARS).trimEnd()
const tail = body.slice(-ACTIVE_NOTE_BODY_TAIL_CHARS).trimStart()
const omittedChars = Math.max(0, body.length - ACTIVE_NOTE_BODY_HEAD_CHARS - ACTIVE_NOTE_BODY_TAIL_CHARS)
return {
body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars)}\n\n${tail}`,
bodyTruncated: {
shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS,
totalChars: body.length,
strategy: 'head-tail',
},
}
}
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
const body = extractBody(activeNoteContent || '')
if (!body && activeEntry.wordCount > 0) {
return { body: unavailableBodyInstruction(activeEntry) }
}
return compactActiveNoteBody(body, activeEntry.path)
}
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent?: string): Record<string, unknown> {
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent)
const note: Record<string, unknown> = {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: bodySnapshot.body,
wordCount: activeEntry.wordCount,
}
assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated)
return note
}
function appendOpenTabs(snapshot: Record<string, unknown>, activeEntry: VaultEntry, openTabs?: VaultEntry[]): void {
const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path)
if (otherTabs && otherTabs.length > 0) {
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (!otherTabs?.length) return
if (noteList && noteList.length > 0) {
const items = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
snapshot.noteList = items
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
snapshot.noteListFilter = noteListFilter
}
function appendNoteList(snapshot: Record<string, unknown>, noteList?: NoteListItem[]): void {
if (!noteList?.length) return
snapshot.noteList = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
}
}
function hasNoteListFilter(noteListFilter?: { type: string | null; query: string }): boolean {
return Boolean(noteListFilter?.type || noteListFilter?.query)
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
if (!references?.length) return
snapshot.referencedNotes = references.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
}
function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA) types.add(e.isA)
}
snapshot.vault = {
return {
types: [...types].sort(),
totalNotes: entries.length,
}
}
if (references && references.length > 0) {
snapshot.referencedNotes = references.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
function contextSnapshot(params: ContextSnapshotParams): Record<string, unknown> {
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent),
}
appendOpenTabs(snapshot, activeEntry, openTabs)
appendNoteList(snapshot, noteList)
if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter
snapshot.vault = vaultSummary(entries)
appendReferencedNotes(snapshot, references)
return snapshot
}
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const snapshot = contextSnapshot(params)
const preamble = [
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'If the body field is empty but wordCount is > 0, the content may be stale — use get_note to read the full note from disk.',
'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')