From 6d86770abf8d7418d56edf6dd5a55e588680dfdb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 2 May 2026 19:24:52 +0200 Subject: [PATCH] fix: compact large ai agent context --- docs/ARCHITECTURE.md | 12 +- src/lib/aiAgentStreamCallbacks.test.ts | 31 ++++ src/lib/aiAgentStreamCallbacks.ts | 14 +- src/utils/ai-context.test.ts | 28 ++++ src/utils/ai-context.ts | 197 ++++++++++++++++++------- 5 files changed, 217 insertions(+), 65 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a7d18bc..2862b4e3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/src/lib/aiAgentStreamCallbacks.test.ts b/src/lib/aiAgentStreamCallbacks.test.ts index 5c047d53..6739649b 100644 --- a/src/lib/aiAgentStreamCallbacks.test.ts +++ b/src/lib/aiAgentStreamCallbacks.test.ts @@ -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([ { diff --git a/src/lib/aiAgentStreamCallbacks.ts b/src/lib/aiAgentStreamCallbacks.ts index 5094ae6b..30c41445 100644 --- a/src/lib/aiAgentStreamCallbacks.ts +++ b/src/lib/aiAgentStreamCallbacks.ts @@ -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) { diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index 624073b0..92f2195d 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -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]) diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index f403646a..f21785b5 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -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, key: string, value: unknown): void { + if (isPresentValue(value)) target[key] = value +} + +function assignIfNonEmpty(target: Record, 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 { const fm: Record = {} - 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 = { - 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 { + const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent) + const note: Record = { + 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, 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, 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, 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 { const types = new Set() 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 { + const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params + const snapshot: Record = { + 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')