Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/app/agent/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ def fmt(payload: dict) -> str:
total = getattr(usage_data, "total_tokens", None)
if total is not None:
usage_payload["totalTokens"] = total
# Forward prompt-cache hit count so the client can observe cache
# effectiveness (OpenRouter-native accounting; may be absent on
# providers without caching).
finish_details = getattr(usage_data, "prompt_tokens_details", None)
finish_cached = getattr(finish_details, "cached_tokens", None) if finish_details else None
if finish_cached is not None:
usage_payload["cachedTokens"] = finish_cached
finish_event["messageMetadata"] = {"usage": usage_payload}

yield fmt(finish_event)
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export interface ThreadSummaryRecord {
threadId: string
summary: string
coversThroughMessageId: string
/**
* Position of the cut in the history at summary time. Lets compaction trim by
* index when the id can't be found (paginated/restored threads), instead of
* falling back to sending the full untrimmed history. Optional for back-compat.
*/
coversThroughIndex?: number
tokenBudget: number
createdAt: number
}
Expand Down
43 changes: 29 additions & 14 deletions frontend/src/features/agent/application/useZoberChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import {
estimateTokens,
normalizeMessagesForBackend,
PAGE_SIZE,
TOKEN_BUDGET,
pruneToFit,
readUsageTokens,
USABLE,
} from '@/features/agent/lib/agent-utils'
import { buildPrompt, resolveThreadId } from '@/features/agent/lib/context-assembler'
import { buildHistoryToStore, maybeRunBackgroundSummary } from '@/features/agent/lib/context-assembler/background-summary'
import { buildHistoryToStore, maybeCompact } from '@/features/agent/lib/context-assembler/background-summary'
import { computeLessonExhaustion } from '@/features/agent/lib/context-assembler/exhaustion'
import { ToolExecutor } from '@/features/agent/lib/tools/executor'
import {
Expand All @@ -51,8 +53,6 @@ import { getEffectiveDueItems } from '@/shared/lib/skillSessionProgress'
const MAX_TOOL_ROUNDS_LESSON = 5
const MAX_TOOL_ROUNDS_GLOBAL = 5
const MAX_INPUT_CHARS = 8000
const TOKEN_BUDGET_SOFT = 0.8
const TOKEN_BUDGET_HARD = 1.0

type AgentActionsDispatch = (action: AgentAction) => void

Expand Down Expand Up @@ -125,6 +125,9 @@ export function useZoberChat(args: ZoberChatArgs) {
const toolCallCountRef = useRef(0)
const errorCountRef = useRef(0)
const exercisesThisSessionRef = useRef(0)
// Real token usage from the last completed turn (when the backend reports it),
// used as the primary overflow signal for compaction; undefined → fall back to estimate.
const lastUsageTokensRef = useRef<number | undefined>(undefined)

// Live context built into a ref to avoid stale closures in transport
const ctxRef = useRef<any>(null)
Expand Down Expand Up @@ -310,19 +313,23 @@ export function useZoberChat(args: ZoberChatArgs) {

const builtPrompt = ctx ? buildPrompt(ctx) : ''
const includeTools = !ctx?.lesson?.exhausted

const projectedTokens = estimateTokens(finalMessages) + estimateTextTokens(builtPrompt)

if (projectedTokens > TOKEN_BUDGET_HARD * TOKEN_BUDGET) {
throw new Error('Conversation too long. Please start a new chat.')
}
if (projectedTokens > TOKEN_BUDGET_SOFT * TOKEN_BUDGET) {
console.warn(`[useZoberChat] Approaching context limit: ${projectedTokens} / ${TOKEN_BUDGET}`)
const systemTokens = estimateTextTokens(builtPrompt)

// No hard block. Idle compaction (maybeCompact, post-response) is the
// primary sizing mechanism; here we apply the LLM-free pruneToFit
// backstop so a send is never refused. With a 1M window this rarely fires.
let outgoing = finalMessages
let projectedTokens = estimateTokens(outgoing) + systemTokens
if (projectedTokens > USABLE) {
outgoing = pruneToFit(outgoing, USABLE - systemTokens)
projectedTokens = estimateTokens(outgoing) + systemTokens
if (projectedTokens > USABLE)
console.warn(`[useZoberChat] still over budget after prune: ${projectedTokens} / ${USABLE}`)
}

return {
body: {
messages: finalMessages,
messages: outgoing,
system_prompt: builtPrompt,
openrouter_api_key: apiKey || null,
tools: includeTools ? getToolDefinitions(toolPool) : [],
Expand Down Expand Up @@ -404,6 +411,12 @@ export function useZoberChat(args: ZoberChatArgs) {
console.error('Agent chat error:', err)
toast.error(err.message || 'Unknown error')
},
onFinish({ message }) {
// Capture real usage if the backend streams it on message metadata, so
// compaction keys off actual token counts (opencode-style) rather than the
// CJK estimate. undefined when absent → maybeCompact falls back to estimate.
lastUsageTokensRef.current = readUsageTokens(message)
},
})

// Load persisted thread from IDB and hydrate useChat state via setMessages.
Expand Down Expand Up @@ -486,7 +499,9 @@ export function useZoberChat(args: ZoberChatArgs) {
exercisesCompleted: exercisesThisSessionRef.current,
})
}
void maybeRunBackgroundSummary(db, threadId, fullHistory, apiKey, API_BASE, locale)
// Post-response, idle: compact when the turn reached the usable budget.
// Prefers real usage from this turn; falls back to the CJK estimate.
void maybeCompact(db, threadId, fullHistory, apiKey, API_BASE, locale, lastUsageTokensRef.current)
})()
}, [status, messages, db, narrowed, threadId, apiKey, locale])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('agent Utils Compaction Pipeline', () => {
expect(hasText, 'The text response was lost').toBe(true)
})

it('lobotomy Bug: compactForTokenBudget should never stub GUIDANCE_TOOLS', () => {
it('lobotomy Bug: the normalize pipeline should never stub GUIDANCE_TOOLS', () => {
// Simulate a message way over budget to force compaction of old messages
const heavyString = 'x'.repeat(64_000 * 5)

Expand Down
112 changes: 71 additions & 41 deletions frontend/src/features/agent/lib/agent-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { UIMessage } from 'ai'
import { describe, expect, it } from 'vitest'
import { compactForTokenBudget, estimateTokens, normalizeMessagesForBackend } from '@/features/agent/lib/agent-utils'
import { estimateTokens, isOverflow, normalizeMessagesForBackend, pruneToFit, readUsageTokens, USABLE } from '@/features/agent/lib/agent-utils'

/**
* Build a UIMessage for tests. Parts often carry extra fields (toolName, args)
Expand Down Expand Up @@ -436,17 +436,56 @@ describe('estimateTokens', () => {
})
})

describe('compactForTokenBudget', () => {
describe('readUsageTokens', () => {
it('reads canonical camelCase metadata.usage.totalTokens', () => {
expect(readUsageTokens({ metadata: { usage: { totalTokens: 22889 } } })).toBe(22889)
})

it('accepts snake_case total_tokens (raw OpenRouter shape)', () => {
expect(readUsageTokens({ metadata: { usage: { total_tokens: 22889 } } })).toBe(22889)
})

it('falls back to promptTokens when total is absent', () => {
expect(readUsageTokens({ metadata: { usage: { promptTokens: 22213 } } })).toBe(22213)
expect(readUsageTokens({ metadata: { usage: { prompt_tokens: 22213 } } })).toBe(22213)
})

it('reads usage placed directly on metadata', () => {
expect(readUsageTokens({ metadata: { totalTokens: 100 } })).toBe(100)
})

it('returns undefined when absent or non-numeric (→ estimate fallback)', () => {
expect(readUsageTokens({})).toBeUndefined()
expect(readUsageTokens(null)).toBeUndefined()
expect(readUsageTokens({ metadata: {} })).toBeUndefined()
expect(readUsageTokens({ metadata: { usage: { totalTokens: 0 } } })).toBeUndefined()
expect(readUsageTokens({ metadata: { usage: { totalTokens: 'x' } } })).toBeUndefined()
})
})

describe('isOverflow', () => {
it('true iff tokens reach the usable budget', () => {
expect(isOverflow(USABLE - 1)).toBe(false)
expect(isOverflow(USABLE)).toBe(true)
expect(isOverflow(USABLE + 1)).toBe(true)
})

it('honours an explicit budget', () => {
expect(isOverflow(100, 200)).toBe(false)
expect(isOverflow(200, 200)).toBe(true)
})
})

describe('pruneToFit', () => {
it('returns messages unchanged when under budget', () => {
const messages = [
msg({ id: '1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }),
msg({ id: '2', role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }),
]
const result = compactForTokenBudget(messages, 10000, 6)
expect(result).toEqual(messages)
expect(pruneToFit(messages, 10000, 6)).toEqual(messages)
})

it('stubs tool results in older messages when over budget', () => {
it('stubs tool results in older messages when over budget, keeps protected tail full', () => {
const messages = [
msg({
id: '1',
Expand All @@ -462,24 +501,34 @@ describe('compactForTokenBudget', () => {
msg({ id: '3', role: 'assistant', parts: [{ type: 'text', text: 'response' }] }),
msg({ id: '4', role: 'user', parts: [{ type: 'text', text: 'next' }] }),
]
const result = compactForTokenBudget(messages, 500, 2)
// Tail kept verbatim
const result = pruneToFit(messages, 500, 2)
// Never deletes — length unchanged
expect(result.length).toBe(messages.length)
// Protected tail kept verbatim
expect(result.at(-1)).toEqual(messages.at(-1))
expect(result.at(-2)).toEqual(messages.at(-2))
// Old tool result should be stubbed
const oldPart = part(result[0] as UIMessage)
expect(oldPart.output).not.toContain('x'.repeat(100))
// Old tool result stubbed
expect(part(result[0] as UIMessage).output).not.toContain('x'.repeat(100))
})

it('drops oldest messages if still over budget after stubbing', () => {
const messages = Array.from({ length: 20 }, (_, i) => msg({
id: `${i}`,
role: i % 2 === 0 ? 'user' as const : 'assistant' as const,
parts: [{ type: 'text', text: 'a'.repeat(500) }],
}))
const result = compactForTokenBudget(messages, 1000, 4)
expect(result.length).toBeLessThan(messages.length)
expect(result.at(-1)!.id).toBe('19')
it('never stubs guidance tools', () => {
const messages = [
msg({
id: '1',
role: 'assistant',
parts: [{
type: 'tool-get_core_guidelines',
toolName: 'get_core_guidelines',
state: 'output-available',
output: 'CORE RULES '.repeat(500),
}],
}),
msg({ id: '2', role: 'user', parts: [{ type: 'text', text: 'hi' }] }),
msg({ id: '3', role: 'assistant', parts: [{ type: 'text', text: 'hello' }] }),
]
const result = pruneToFit(messages, 100, 1)
expect(result.length).toBe(messages.length)
expect(part(result[0] as UIMessage).output).toContain('CORE RULES')
})

it('preserves user and assistant text in older messages', () => {
Expand All @@ -490,34 +539,15 @@ describe('compactForTokenBudget', () => {
role: 'assistant',
parts: [
{ type: 'text', text: 'important answer' },
{
type: 'tool-get_vocabulary',
toolName: 'get_vocabulary',
state: 'output-available',
output: 'x'.repeat(3000),
},
{ type: 'tool-get_vocabulary', toolName: 'get_vocabulary', state: 'output-available', output: 'x'.repeat(3000) },
],
}),
msg({ id: '3', role: 'user', parts: [{ type: 'text', text: 'follow-up' }] }),
]
const result = compactForTokenBudget(messages, 500, 1)
// User text preserved
const result = pruneToFit(messages, 500, 1)
expect(part(result[0] as UIMessage).text).toBe('important question')
// Assistant text preserved, tool result stubbed
const assistantParts = (result[1] as any).parts
expect(assistantParts.find((p: any) => p.type === 'text')?.text).toBe('important answer')
})

it('does not start on a tool-role message', () => {
const messages = [
msg({ id: '0', role: 'assistant' as any, parts: [{ type: 'tool-x', toolName: 'x', state: 'output-available', output: 'y' }] }),
msg({ id: '1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }),
msg({ id: '2', role: 'assistant', parts: [{ type: 'text', text: 'hello' }] }),
]
// Budget so low that message 0 would be dropped
const result = compactForTokenBudget(messages, 100, 2)
if (result.length < messages.length) {
expect(result[0]?.role).not.toBe('tool')
}
expect(assistantParts.find((p: any) => p.type?.startsWith('tool-'))?.output).not.toContain('x'.repeat(100))
})
})
Loading
Loading