Skip to content

Bug: les streams sans usage remettent le contexte de session à 0 et empêchent la compaction #211

Description

@tawamaka

Bonjour,

J’ai identifié un problème dans main concernant le suivi de contexte des réponses streaming OpenAI-compatible. Il est reproductible lorsque le provider termine un stream sans fournir de bloc usage — observé avec des appels GPT-only.

Comportement actuel
Le chemin concerné est :

src/server/llm/client.ts
src/server/chat/agent-loop.ts
src/server/session/manager.ts
Dans client.ts, l’usage streaming est initialisé à :

ts

let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
Il n’est remplacé que lorsqu’un chunk contient effectivement chunk.usage :

ts

if (chunk.usage) {

usage = {

promptTokens: chunk.usage.prompt_tokens,

completionTokens: chunk.usage.completion_tokens,

totalTokens: chunk.usage.total_tokens,

}

}
À la fin du stream, la réponse done retourne toujours cet objet usage, même lorsqu’aucun chunk n’a fourni de métrique. Dans ce cas, l’usage retourné vaut donc artificiellement zéro.

Ensuite, agent-loop.ts appelle inconditionnellement :

ts

sessionManager.setCurrentContextSize(

sessionId,

result.usage.promptTokens,

config.subAgentMetadata?.subAgentId,

)
Enfin, manager.ts persiste ce 0 comme nouvel état effectif via context.state.

Conséquences
Après une réponse streaming sans usage :

Le contexte affiché/persisté tombe à 0, même si le contexte réel est élevé.
Le dernier contexte fiable est perdu.
La vérification de compaction qui suit utilise ce 0.
La compaction ne se déclenche donc pas au moment requis.
La session peut continuer jusqu’au dépassement réel de la fenêtre de contexte.
Exemple : après un contexte confirmé à 350_000 tokens, une réponse GPT streaming sans usage ramène l’état à 0, alors qu’il devrait rester à 350_000 tant qu’aucune métrique fiable ne remplace cette valeur.

C’est particulièrement problématique avec de grandes fenêtres, par exemple GPT‑5.6 Terra configuré autour de 1M tokens : la perte du suivi local empêche la compaction préventive et augmente le risque de dépassement.

Correctif proposé
Le problème de fond est que 0 et « métrique absente » sont actuellement indiscernables. Je propose de représenter explicitement cette distinction.

  1. Ajouter un indicateur dans l’usage interne
    Dans src/server/llm/types.ts, compléter LLMCompletionResponse.usage :

ts

usage: {

promptTokens: number

completionTokens: number

totalTokens: number

reported: boolean

}
reported est initialisé à false et ne passe à true que lorsqu’un provider retourne réellement chunk.usage.

  1. Préserver cette information dans client.ts
    ts

let usage = {

promptTokens: 0,

completionTokens: 0,

totalTokens: 0,

reported: false,

}

if (chunk.usage) {

usage = {

promptTokens: chunk.usage.prompt_tokens,

completionTokens: chunk.usage.completion_tokens,

totalTokens: chunk.usage.total_tokens,

reported: true,

}

}
Le done final retourne ensuite cet objet sans convertir une absence de métrique en une mesure à zéro.

  1. Ne mettre à jour le contexte que lorsqu’il est rapporté
    Dans agent-loop.ts :

ts

if (result.usage.reported) {

sessionManager.setCurrentContextSize(

sessionId,

result.usage.promptTokens,

config.subAgentMetadata?.subAgentId,

)

} else {

logger.warn('LLM stream completed without usage metrics', {

sessionId,

model: llmClient.getModel(),

providerId: session.providerId,

})

}
Ainsi, une absence de métrique conserve le dernier context.state fiable au lieu de l’écraser avec zéro.

La vérification de compaction doit également s’appuyer sur cet état conservé, et ne jamais déclencher — ou supprimer une compaction nécessaire — en raison d’une métrique absente.

Pourquoi ne pas simplement ignorer les zéros ?
Un compteur à zéro peut être une valeur réellement renvoyée par un provider, un mock ou un cas limite. L’absence de métrique est une information différente ; elle doit donc être représentée explicitement par un indicateur typé tel que reported.

Tests de régression suggérés
Stream sans usage après un contexte connu
Initialiser la session avec currentTokens = 350_000.
Simuler une réponse streaming valide sans aucun chunk.usage.
Vérifier que setCurrentContextSize n’est pas appelé.
Vérifier que currentTokens reste à 350_000.
Vérifier qu’un avertissement structuré est journalisé, sans contenu conversationnel.
Vérifier que la logique de compaction n’évalue pas un contexte à zéro.
Stream avec usage
Simuler une réponse contenant prompt_tokens, completion_tokens et total_tokens.
Vérifier que reported === true.
Vérifier que setCurrentContextSize est appelé avec prompt_tokens.
Vérifier que la mise à jour du contexte et la compaction conservent le comportement actuel.
Test unitaire du client
Sans chunk.usage : usage.reported === false.
Avec chunk.usage : usage.reported === true et les compteurs sont correctement mappés.
Merci.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions