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
6 changes: 3 additions & 3 deletions .cursor/rules/sdk/docs/kv-cache-system.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ When a new cache key is used for the first time:

- `cacheKey` β€” User-provided or auto-generated session identifier
- `modelId` β€” Unique identifier for the loaded model instance
- `configHash` β€” Hash of system prompt + tool names (ensures cache validity)
- `configHash` β€” Hash of system prompt + complete canonical tool definitions (ensures cache validity)

### Auto-Cache Retention

Expand Down Expand Up @@ -177,7 +177,7 @@ Injecting the prime via closure keeps `kv-cache-session.ts` free of model-regist

### Config Hash Generation (generateConfigHash)

Cache validity is tied to a SHA-256 hash of system prompt content + sorted tool names. Model config is NOT included (per addon team: doesn't affect cache validity). Changing tools mid-session creates a new cache with the new tools anchored. Dynamic-mode tools intentionally do NOT participate in the hash so the cache can survive per-turn tool sets.
Cache validity is tied to a SHA-256 hash of the system prompt content + complete canonical tool definitions. Object keys are sorted recursively to avoid cache misses caused only by insertion order; tool-array order is preserved because it matches the prompt sent to the model. Model config is NOT included (per addon team: doesn't affect cache validity). Changing any prompt-affecting tool field mid-session creates a new cache with the new tools anchored.

### Auto-Cache Rename Flow

Expand Down Expand Up @@ -233,7 +233,7 @@ Enable with `loggerLevel: "debug"` in config. Logs from `cache-logger.ts` show:

## MCP Compatibility

KV Cache works with MCP tools. Only tool names are hashed (sorted alphabetically) β€” tool order, descriptions, and parameter changes don't affect cache key. Adding/removing tools creates a new cache.
KV Cache works with MCP tools. Complete canonical tool definitions are hashed: object-key insertion order does not affect the cache key, while tool-array order and changes to names, descriptions, or parameters create a new cache.

## Common Issues

Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/sdk/public-constants-contract.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ alwaysApply: false

`packages/sdk/index.ts` re-exports plain constants alongside RPC methods β€”
`ModelType`, `MODEL_TYPES`, `PLUGIN_LLM` and friends, `SUPPORTED_AUDIO_FORMATS`,
`TOOLS_MODE`, `VERBOSITY`. Non-JS SDKs (Python, ...) are generated from
`VERBOSITY`. Non-JS SDKs (Python, ...) are generated from
`packages/sdk/contract/{schema.json,manifest.json,models.json}` β€” they never
read `index.ts` directly. A constant that isn't in `schema.json`'s `$defs`
is invisible to every other language, no matter how prominently it's
Expand Down
4 changes: 2 additions & 2 deletions packages/inference/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
"@qvac/diffusion-cpp": "^0.17.0",
"@qvac/embed-llamacpp": "^0.30.1",
"@qvac/langdetect-text": "^0.1.2",
"@qvac/llm-llamacpp": "^0.39.3",
"@qvac/llm-llamacpp": "^0.43.0",
"@qvac/ocr-ggml": "^0.13.1",
"@qvac/translation-nmtcpp": "^8.3.0",
"@qvac/tts-ggml": "^0.6.0",
Expand Down Expand Up @@ -249,7 +249,7 @@
"@qvac/diffusion-cpp": "^0.17.0",
"@qvac/embed-llamacpp": "^0.30.1",
"@qvac/langdetect-text": "^0.1.2",
"@qvac/llm-llamacpp": "^0.39.3",
"@qvac/llm-llamacpp": "^0.43.0",
"@qvac/ocr-ggml": "^0.13.1",
"@qvac/translation-nmtcpp": "^8.3.0",
"@qvac/tts-ggml": "^0.6.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type {
ResponseFormat,
Tool
} from '@/schemas/index'
import { TOOLS_MODE } from '@/schemas/tools'
import { getModel, getModelConfig, type AnyModel } from '@/runtime/model-registry'
import type { DisposableScope } from '@/runtime/disposable-scope'
import type { Logger } from '@/logging/types'
Expand All @@ -19,7 +18,7 @@ import {
type CompletionGenerationParams
} from '@/plugins/builtin/llamacpp-completion/ops/completion-stream'
import { normalizeCompletionStats } from '@/plugins/builtin/llamacpp-completion/ops/completion-stats'
import { appendToolsToHistory, prependToolsToHistory } from '@/utils/tool-integration'
import { prependToolsToHistory } from '@/utils/tool-integration'

const logger = getEngineLogger()

Expand Down Expand Up @@ -69,7 +68,6 @@ type BatchModelStreamResult = {

type BatchPromptRenderOptions = {
toolsEnabled: boolean
toolsMode?: string | undefined
}

function runBatchModel(model: AnyModel, prompts: AddonBatchPrompt[]) {
Expand Down Expand Up @@ -104,10 +102,7 @@ function renderPromptHistory(
let historyWithTools: Array<HistoryMessage | Tool> = prompt.history

if (tools) {
historyWithTools =
options.toolsMode === TOOLS_MODE.dynamic
? appendToolsToHistory(prompt.history, tools)
: prependToolsToHistory(prompt.history, tools)
historyWithTools = prependToolsToHistory(prompt.history, tools)
}

// Uses the same attachment expansion as single completion: each
Expand Down Expand Up @@ -160,8 +155,7 @@ export async function* batchCompletion(
const model = getModel(modelId)
const modelConfig = getModelConfig(modelId)
const renderOptions: BatchPromptRenderOptions = {
toolsEnabled: (modelConfig as { tools?: boolean }).tools === true,
toolsMode: (modelConfig as { toolsMode?: string }).toolsMode
toolsEnabled: (modelConfig as { tools?: boolean }).tools === true
}

const onAbort = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
ToolCall,
ToolDialect
} from '@/schemas/index'
import { TOOLS_MODE } from '@/schemas/tools'
import {
logCacheDisabled,
logCacheInit,
Expand All @@ -29,11 +28,7 @@ import {
type TurnHandle
} from '@/plugins/builtin/llamacpp-completion/ops/kv-cache-session'
import type { DisposableScope } from '@/runtime/disposable-scope'
import {
appendToolsToHistory,
detectToolDialect,
prependToolsToHistory
} from '@/utils/tool-integration'
import { detectToolDialect, prependToolsToHistory } from '@/utils/tool-integration'
import { parseToolCalls } from '@/utils/tools/index'
import { getResponseFormatJsonSchema } from '@/utils/response-format'
import { buildAutoCacheSaveHistory, type CacheMessage } from '@/utils/index'
Expand Down Expand Up @@ -205,30 +200,19 @@ type HistoryMsg = {
attachments?: { path: string }[] | undefined
}

type ToolPlacement = 'static' | 'dynamic'

/**
* Attach the tool block to a turn payload at the position its placement
* requires.
*
* Static mirrors the no-kv-cache path (`prependToolsToHistory`) and keeps the
* block ahead of the conversation. Dynamic must leave it immediately after the
* last anchor message, which is what the addon's `ToolsCompactController`
* validates before it will anchor and later trim the block.
* Attach the tool block ahead of a turn payload, mirroring the no-kv-cache
* path (`prependToolsToHistory`).
*/
function withToolBlock(
messages: ChatHistory[],
toolBlock: ChatHistory[],
placement: ToolPlacement
): ChatHistory[] {
function withToolBlock(messages: ChatHistory[], toolBlock: ChatHistory[]): ChatHistory[] {
if (toolBlock.length === 0) return messages
return placement === 'static' ? [...toolBlock, ...messages] : [...messages, ...toolBlock]
return [...toolBlock, ...messages]
}

interface CachePayload {
messages: ChatHistory[]
/**
* Whether the prefix will hold a rendered static tool block once this turn
* Whether the prefix will hold a rendered tool block once this turn
* commits β€” either it already did, or this payload carries one the template
* will render.
*/
Expand All @@ -251,12 +235,10 @@ function rendersToolBlock(messages: HistoryMsg[], toolBlock: ChatHistory[]): boo
/**
* Pick the messages that need to reach the model for the next turn.
*
* `placement` selects both the slicing strategy and where the tool block sits
* in the payload. Tools are never baked into the primed prefix β€” a prefix with
* no user turn is not a renderable conversation for every template β€” so they
* travel with a turn instead.
* Tools are never baked into the primed prefix β€” a prefix with no user turn is
* not a renderable conversation for every template β€” so they travel with a
* turn instead.
*
* Static placement:
* - Empty history: nothing to slice; send whatever non-system messages
* exist. (The call site always reports the cache as existing, so this
* is the only way into this branch.)
Expand All @@ -269,105 +251,57 @@ function rendersToolBlock(messages: HistoryMsg[], toolBlock: ChatHistory[]): boo
* the bad boundary doesn't propagate into the next turn.
* - The tool block travels only with the turn that writes it into the
* cache; see `skipToolBlock` below.
*
* Dynamic placement:
* - The addon anchors the tool block after the last user message and
* trims tools + the assistant's tool-call output from the cache once
* the chain resolves. After that trim, the cache only holds messages
* up to the last user turn, so we ship the right slice
* plus the (possibly new) tool set:
* * tool-chain continuation (last role is "tool"): send the trailing
* consecutive tool messages, no tool block β€” tools are still
* anchored in the cache from the previous round.
* * new user turn after a chain (prev role is "assistant"): send
* [assistant, user] so the model sees its own final reply before
* the new prompt, then re-anchor the tool block.
* * otherwise: send just the last message + tool block.
*/
function prepareMessagesForCache(
session: KvCacheSession,
turn: TurnHandle,
cacheExists: boolean,
history: HistoryMsg[],
tools?: Tool[],
placement: ToolPlacement = 'static',
toolBlockEvictable = false
): CachePayload {
const toolBlock = tools?.length ? transformMessages(tools) : []

if (!(cacheExists && history.length > 0)) {
const historyWithoutSystem = history.filter((msg) => msg.role !== 'system')
return {
messages: withToolBlock(transformMessages(historyWithoutSystem), toolBlock, placement),
toolBlockCached: placement === 'static' && rendersToolBlock(historyWithoutSystem, toolBlock)
messages: withToolBlock(transformMessages(historyWithoutSystem), toolBlock),
toolBlockCached: rendersToolBlock(historyWithoutSystem, toolBlock)
}
}

if (placement === 'static') {
// Static path β€” slice from the turn's `savedCount` so callers can
// stage multiple messages between completions. `decideCachedHistorySlice`
// also guards against the QVAC-17780 stale-count regression: if the
// saved boundary would slice the history down to an empty payload
// (e.g. after a cancelled mid-decode), it falls back to the full
// non-system history and signals the caller to drop the bad entry.
// The session owns the entry; `dropStaleSavedCount` clears it
// without touching the on-disk file (the file is still trustworthy
// β€” only the boundary count is wrong).
const { messages, clearStaleCount } = decideCachedHistorySlice(
turn.savedCount,
cacheExists,
history
)

if (clearStaleCount) {
session.dropStaleSavedCount(turn)
}

// Static never trims the block back out of the cache, so re-sending it
// every turn would leave one copy per turn and grow the prefix with the
// conversation. Skip it only when the prefix is known to hold a rendered
// one: `toolBlockCached` records that a previous turn actually got it into
// the cache, which a committed message count does not prove. A stale
// boundary means we are resending the whole conversation anyway, and an
// evictable block can no longer be assumed present.
const skipToolBlock = turn.toolBlockCached && !clearStaleCount && !toolBlockEvictable
const blockToSend = skipToolBlock ? [] : toolBlock
// Slice from the turn's `savedCount` so callers can
// stage multiple messages between completions. `decideCachedHistorySlice`
// also guards against the QVAC-17780 stale-count regression: if the
// saved boundary would slice the history down to an empty payload
// (e.g. after a cancelled mid-decode), it falls back to the full
// non-system history and signals the caller to drop the bad entry.
// The session owns the entry; `dropStaleSavedCount` clears it
// without touching the on-disk file (the file is still trustworthy
// β€” only the boundary count is wrong).
const { messages, clearStaleCount } = decideCachedHistorySlice(
turn.savedCount,
cacheExists,
history
)

return {
messages: withToolBlock(transformMessages(messages), blockToSend, placement),
toolBlockCached: skipToolBlock || rendersToolBlock(messages, blockToSend)
}
if (clearStaleCount) {
session.dropStaleSavedCount(turn)
}

// Dynamic path. The addon trimmed tools after the previous round, so the
// cache no longer holds the saved-count we'd rely on for slicing β€” pick
// the right fragment based on the role of the last history message. Nothing
// tool-specific survives that trim, so the prefix never counts as holding a
// block and every turn re-anchors its own.
const lastMsg = history[history.length - 1]!

if (lastMsg.role === 'tool') {
const trailingTools: HistoryMsg[] = []
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i]!
if (msg.role !== 'tool') break
trailingTools.unshift(msg)
}
return { messages: transformMessages(trailingTools), toolBlockCached: false }
}

if (lastMsg.role === 'user') {
const prevMsg = history[history.length - 2]
const tail = prevMsg?.role === 'assistant' ? [prevMsg, lastMsg] : [lastMsg]
return {
messages: withToolBlock(transformMessages(tail), toolBlock, placement),
toolBlockCached: false
}
}
// The block is never trimmed back out of the cache, so re-sending it every
// turn would leave one copy per turn and grow the prefix with the
// conversation. Skip it only when the prefix is known to hold a rendered
// one: `toolBlockCached` records that a previous turn actually got it into
// the cache, which a committed message count does not prove. A stale
// boundary means we are resending the whole conversation anyway, and an
// evictable block can no longer be assumed present.
const skipToolBlock = turn.toolBlockCached && !clearStaleCount && !toolBlockEvictable
const blockToSend = skipToolBlock ? [] : toolBlock

return {
messages: withToolBlock(transformMessages([lastMsg]), toolBlock, placement),
toolBlockCached: false
messages: withToolBlock(transformMessages(messages), blockToSend),
toolBlockCached: skipToolBlock || rendersToolBlock(messages, blockToSend)
}
}

Expand Down Expand Up @@ -458,16 +392,12 @@ export async function* completion(

const modelConfig = getModelConfig(modelId)
const toolsEnabled = (modelConfig as { tools?: boolean }).tools === true
const toolsMode = (modelConfig as { toolsMode?: string }).toolsMode
const toolsActive = !!tools?.length && toolsEnabled
const dynamicTools = toolsActive && toolsMode === TOOLS_MODE.dynamic
const staticTools = toolsActive && !dynamicTools
// Sliding is opt-in (`n_discarded` defaults to 0). Once on, the addon's
// discard window opens at the end of the primed prefix β€” which is where a
// static tool block sits, since the prime is the system prompt alone β€” and
// the clamp that would protect it only runs in dynamic mode. So while
// sliding is possible the block cannot be assumed to survive, and it has to
// travel with every turn.
// discard window opens at the end of the primed prefix β€” which is where the
// tool block sits, since the prime is the system prompt alone β€” and nothing
// protects it. So while sliding is possible the block cannot be assumed to
// survive, and it has to travel with every turn.
const toolBlockEvictable = ((modelConfig as { n_discarded?: number }).n_discarded ?? 0) > 0

const dialect =
Expand Down Expand Up @@ -536,10 +466,8 @@ export async function* completion(
if (!kvCache) {
// KV-cache disabled β€” straight passthrough, no session involvement.
let historyWithTools: Array<HistoryMsg | Tool> = history
if (staticTools && tools) {
if (toolsActive && tools) {
historyWithTools = prependToolsToHistory(history, tools)
} else if (dynamicTools && tools) {
historyWithTools = appendToolsToHistory(history, tools)
}

const transformedHistory = transformMessages(historyWithTools)
Expand All @@ -564,11 +492,10 @@ export async function* completion(

const session = createKvCacheSession(modelId, { logger: requestLogger })
const systemPromptFromHistory = extractSystemPrompt(history)
// Static bakes the tool block into the cache on the turn that first sends it
// and never trims it, so a late or changed tool set has to land on a fresh
// cache rather than a warm prefix holding the old block. Dynamic trims its
// block after each chain, so nothing tool-specific survives in its cache.
const configHash = generateConfigHash(systemPromptFromHistory, staticTools ? tools : undefined)
// The tool block is baked into the cache on the turn that first sends it and
// never trimmed, so a late or changed tool set has to land on a fresh cache
// rather than a warm prefix holding the old block.
const configHash = generateConfigHash(systemPromptFromHistory, toolsActive ? tools : undefined)

const systemPromptToUse =
systemPromptFromHistory ||
Expand Down Expand Up @@ -622,7 +549,6 @@ export async function* completion(
/* cacheExists */ true,
history,
toolsActive ? tools : undefined,
dynamicTools ? 'dynamic' : 'static',
toolBlockEvictable
)
const messagesToSend = payload.messages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export interface BeginCustomTurnInput {
kind: 'custom'
/** User-provided session key (`completion({ kvCache: "session-a" })`). */
customKey: string
/** Hash of system prompt + (static) tool names. */
/** Hash of system prompt + complete tool definitions. */
configHash: string
/**
* Prime the cache by sending the system prompt to the addon. Tools are not
Expand All @@ -233,7 +233,7 @@ export interface BeginCustomTurnInput {

export interface BeginAutoTurnInput {
kind: 'auto'
/** Hash of system prompt + (static) tool names. */
/** Hash of system prompt + complete tool definitions. */
configHash: string
/** Conversation history used to compute the pre-response cache key. */
history: CacheMessage[]
Expand Down
Loading
Loading