Repository navigation
π feat: AgentContext.projectContextUsage: pre-send context snapshot #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b7be222
93c970c
a90f8d1
1e1c691
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,6 @@ import type { | |
| BaseMessageFields, | ||
| } from '@langchain/core/messages'; | ||
| import type { RunnableConfig, Runnable } from '@langchain/core/runnables'; | ||
| import type { createPruneMessages } from '@/messages'; | ||
| import type * as t from '@/types'; | ||
| import { | ||
| ANTHROPIC_TOOL_TOKEN_MULTIPLIER, | ||
|
|
@@ -19,10 +18,16 @@ import { | |
| import { | ||
| addCacheControl, | ||
| addCacheControlToStablePrefixMessages, | ||
| cloneMessage, | ||
| } from '@/messages/cache'; | ||
| import { createSchemaOnlyTools } from '@/tools/schema'; | ||
| import { apportionTokenCounts } from '@/utils/tokens'; | ||
| import { DEFAULT_RESERVE_RATIO } from '@/messages'; | ||
| import { | ||
| DEFAULT_RESERVE_RATIO, | ||
| createPruneMessages, | ||
| syncBudgetDerivedFields, | ||
| } from '@/messages'; | ||
| import { isThinkingEnabled } from '@/llm/request'; | ||
| import { toJsonSchema } from '@/utils/schema'; | ||
|
|
||
| type AgentSystemTextBlock = { | ||
|
|
@@ -1330,6 +1335,102 @@ export class AgentContext { | |
| return lines.join('\n'); | ||
| } | ||
|
|
||
| /** | ||
| * Projects the context-usage snapshot for an arbitrary message set WITHOUT | ||
| * invoking the model β the pre-send / page-load / window-switch counterpart to | ||
| * the live `ON_CONTEXT_USAGE` snapshot. Runs the same pruner + budget math the | ||
| * graph uses (`createPruneMessages` β `getTokenBudgetBreakdown` β | ||
| * `syncBudgetDerivedFields`) so projected numbers match a real call. Returns | ||
| * null when the context lacks the tokenizer or window needed to prune. Omits | ||
| * the live post-format reconciliation (provider-specific, invoke-time) β a | ||
| * small, acceptable delta for a pre-send estimate. | ||
| * | ||
| * Safe to call off the hot path: the supplied `messages` are never mutated | ||
| * (each is passed as a clone β the pruner both replaces tool-result slots and | ||
| * unshifts reasoning blocks into AI content arrays in place), and this | ||
| * context's own state is untouched apart from refreshing stale instruction | ||
| * counts (idempotent, exactly what a real call does). Token counts are | ||
| * recounted for the supplied messages (the context's `indexTokenCountMap` is | ||
| * keyed to the live run's branch and would missum an arbitrary branch) unless | ||
| * the caller passes a map it guarantees matches. Calibration is NOT re-derived | ||
| * from this context's live usage (a fresh pruner would compare the prior | ||
| * call's provider input against the whole projected branch); the learned | ||
| * `calibrationRatio` is applied as a static seed, and callers may override it | ||
| * with a persisted ratio via `opts.calibrationRatio`. | ||
| */ | ||
| projectContextUsage( | ||
| messages: BaseMessage[], | ||
| opts?: { | ||
| runId?: string; | ||
| agentId?: string; | ||
| calibrationRatio?: number; | ||
| indexTokenCountMap?: Record<string, number | undefined>; | ||
| } | ||
| ): t.ContextUsageEvent | null { | ||
| const tokenCounter = this.tokenCounter; | ||
| if (tokenCounter == null || this.maxContextTokens == null) { | ||
| return null; | ||
| } | ||
| /** Refresh stale system overhead (handoff/summary changes) so instruction | ||
| * tokens match the prompt a real call would send. */ | ||
| this.initializeSystemRunnable(); | ||
| /** Clone array-content messages: the pruner unshifts reasoning blocks into | ||
| * AI content arrays in place, which would otherwise corrupt the caller's | ||
| * history. (Slot replacements land on the mapped array, not the caller's.) */ | ||
| const projected = messages.map((message) => | ||
| Array.isArray(message.content) | ||
| ? cloneMessage(message, [...message.content]) | ||
| : message | ||
| ); | ||
| let indexTokenCountMap = opts?.indexTokenCountMap; | ||
| if (indexTokenCountMap == null) { | ||
| indexTokenCountMap = {}; | ||
| for (let i = 0; i < messages.length; i++) { | ||
| indexTokenCountMap[String(i)] = tokenCounter(messages[i]); | ||
| } | ||
| } | ||
| const prune = createPruneMessages({ | ||
| startIndex: 0, | ||
| provider: this.provider, | ||
| tokenCounter, | ||
| maxTokens: this.maxContextTokens, | ||
| thinkingEnabled: isThinkingEnabled(this.provider, this.clientOptions), | ||
| indexTokenCountMap, | ||
| contextPruningConfig: this.contextPruningConfig, | ||
| summarizationEnabled: this.summarizationEnabled, | ||
| reserveRatio: this.summarizationConfig?.reserveRatio, | ||
| calibrationRatio: opts?.calibrationRatio ?? this.calibrationRatio, | ||
| getInstructionTokens: () => this.instructionTokens, | ||
| }); | ||
| const { | ||
| context, | ||
| prePruneContextTokens, | ||
| remainingContextTokens, | ||
| contextBudget, | ||
| effectiveInstructionTokens, | ||
| calibrationRatio, | ||
| } = prune({ | ||
| messages: projected, | ||
| usageMetadata: undefined, | ||
| lastCallUsage: undefined, | ||
| totalTokensFresh: false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with πΒ / π.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch β |
||
| }); | ||
| const breakdown = this.getTokenBudgetBreakdown(messages); | ||
| breakdown.messageCount = context.length; | ||
| const usage: t.ContextUsageEvent = { | ||
| runId: opts?.runId, | ||
| agentId: opts?.agentId, | ||
| breakdown, | ||
| contextBudget, | ||
| effectiveInstructionTokens, | ||
| prePruneContextTokens, | ||
| remainingContextTokens, | ||
| calibrationRatio, | ||
| }; | ||
| syncBudgetDerivedFields(usage); | ||
| return usage; | ||
| } | ||
|
|
||
| /** | ||
| * Updates the last-call usage with data from the most recent LLM response. | ||
| * Unlike `currentUsage` which accumulates, this captures only the single call. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the context has been marked
systemRunnableStale(for example aftersetHandoffContext,setInitialSummary, or clearing/changing summary state), the live graph refreshesagentContext.systemRunnablebefore pruning, which recountssystemMessageTokens; this projection path never does that, sogetInstructionTokenscan use stale overhead and under- or over-estimate pruning for transferred agents or updated system prompts. Callers using the projection for those branches can get a snapshot that no longer matches the prompt a real call would send.Useful? React with πΒ / π.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in f8fd752.
projectContextUsagenow callsthis.initializeSystemRunnable()first (idempotent β recomputes only when stale), mirroring the live graph's refresh before pruning, so handoff/summary changes recountsystemMessageTokensinstead of projecting with stale instruction overhead. Test:setHandoffContext(marks stale, defers the recount) followed by a projection recomputessystemMessageTokenshigher.