Skip to content
Open
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
41 changes: 36 additions & 5 deletions src/agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { BaseCheckpointSaver, CheckpointTuple } from "@langchain/langgraph"
import { MemorySaver } from "@langchain/langgraph";
import { type ReactAgent, createAgent, summarizationMiddleware } from "langchain";
import { createSubAgentMiddleware } from "deepagents/browser";
import { TFile } from "obsidian";
import { Notice, TFile } from "obsidian";

import { ProviderEndpointError } from "../providers/errors";
import type { ChatModelConfig } from "../providers/index";
Expand All @@ -27,6 +27,7 @@ import {
import { toBase64, toBase64DataUri } from "../utils/attachments";
import { extractTextFromPdf } from "../utils/pdfExtractor";
import { Logger } from "../utils/logging";
import { countMessageTokens, estimateSystemPromptTokens } from "../utils/tokenEstimator";
import { normalizeMessages } from "./messageNormalization";
import { type ThreadSnapshot, type ThreadStore, createSnapshot } from "./memory/ThreadStore";
import {
Expand Down Expand Up @@ -613,7 +614,7 @@ export class Agent {
systemPrompt,
checkpointer: this.checkpointer,
middleware: [
...this.buildSummarizationMiddleware(selectedModel, resolvedSummarization),
...this.buildSummarizationMiddleware(selectedModel, systemPrompt, resolvedSummarization),
...this.buildSubAgentMiddleware(selectedModel, subAgents, tools),
] as never,
});
Expand All @@ -632,7 +633,11 @@ export class Agent {
};
}

private buildSummarizationMiddleware(selectedModel: SelectedModel, summarizationModel?: SelectedModel) {
private buildSummarizationMiddleware(
selectedModel: SelectedModel,
systemPrompt: string,
summarizationModel?: SelectedModel,
) {
const model = summarizationModel?.instance ?? selectedModel.instance;
const contextWindow = selectedModel.options?.contextWindow;
const triggerTokens = getSummarizationTriggerTokens(contextWindow);
Expand All @@ -643,20 +648,42 @@ export class Agent {
return [];
}

const trimTokensToSummarize = getTrimTokensToSummarize(triggerTokens);
// The summarizer reads what it replaces, bounded by its OWN window: the
// dedicated summary model's when one is set, else the chat model's.
const summarizerContextWindow = summarizationModel ? summarizationModel.options?.contextWindow : contextWindow;
const trimTokensToSummarize = getTrimTokensToSummarize(summarizerContextWindow, triggerTokens);
// The middleware only counts messages; the system prompt rides along on
// every request outside them. Lowering the trigger by its size makes it
// fire at the same total the context meter shows (#545).
const messageTriggerTokens = Math.max(triggerTokens - estimateSystemPromptTokens(systemPrompt), 1);

return [
// Guarded so a failed summary aborts the trim instead of replacing the
// trimmed history with an error stub (#435).
guardSummarizationFailure(
summarizationMiddleware({
model,
trigger: { tokens: triggerTokens, messages: SUMMARY_KEEP_MESSAGE_COUNT + 2 },
trigger: { tokens: messageTriggerTokens, messages: SUMMARY_KEEP_MESSAGE_COUNT + 2 },
keep: { messages: SUMMARY_KEEP_MESSAGE_COUNT },
summaryPrefix: SUMMARY_PREFIX,
summaryPrompt: SUMMARY_PROMPT,
tokenCounter: countMessageTokens,
trimTokensToSummarize,
}),
{
tokenCounter: countMessageTokens,
trimTokensToSummarize,
onHistoryDropped: ({ droppedTokens }) => {
new Notice(
`Smart Second Brain: about ${formatThousands(droppedTokens)} tokens of the oldest chat history did not fit the summarization model's context window and were left out of the summary.`,
);
},
onRepeatedFailure: (failures) => {
new Notice(
`Smart Second Brain: summarizing this chat's history failed ${failures} times in a row, so the full history is still sent and may exceed the model's context limit. Check the agent's summarization model; the developer console has the error.`,
);
},
},
),
] as const;
}
Expand Down Expand Up @@ -1949,3 +1976,7 @@ ${userMessage}`;
return undefined;
}
}

function formatThousands(tokens: number): string {
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens);
}
160 changes: 140 additions & 20 deletions src/agent/summarization.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { BaseMessage } from "@langchain/core/messages";
import { Logger } from "../utils/logging";

const SUMMARY_TRIGGER_RATIO = 0.8;
export const SUMMARY_KEEP_MESSAGE_COUNT = 12;
const SUMMARY_MIN_TRIGGER_TOKENS = 12_000;
const SUMMARY_MIN_TRIM_TOKENS = 6_000;
const SUMMARY_MAX_TRIM_TOKENS = 24_000;
// Share of the summarization model's own window the history may fill; the rest
// holds the prompt template and the summary it writes back.
const SUMMARY_INPUT_RATIO = 0.85;
const SUMMARY_MIN_INPUT_TOKENS = 1_000;
export const SUMMARY_FAILURE_NOTICE_THRESHOLD = 3;
export const SUMMARY_PREFIX = "Previous conversation summary";
export const SUMMARY_PROMPT = `Summarize the earlier conversation so the assistant can continue the thread accurately.

Expand All @@ -27,7 +33,24 @@ export function getSummarizationTriggerTokens(contextWindow: number | undefined)
return Math.max(Math.floor(contextWindow * SUMMARY_TRIGGER_RATIO), SUMMARY_MIN_TRIGGER_TOKENS);
}

export function getTrimTokensToSummarize(triggerTokens: number): number {
/**
* How much of the history being replaced the summarization model gets to read.
*
* LangChain trims the to-be-summarized messages to this budget (keeping the
* newest) and then removes ALL of them, so anything over it is lost without a
* trace (#545). The budget is therefore the summarization model's own window,
* minus room for the prompt and the summary, so it reads everything it
* replaces. Only when that window is unknown (a dedicated summary model with no
* configured context window) does it fall back to a conservative fixed cap.
*/
export function getTrimTokensToSummarize(summarizerContextWindow: number | undefined, triggerTokens: number): number {
if (summarizerContextWindow && summarizerContextWindow > 0) {
const promptTokens = Math.ceil(SUMMARY_PROMPT.length / 4);
return Math.max(
Math.floor(summarizerContextWindow * SUMMARY_INPUT_RATIO) - promptTokens,
SUMMARY_MIN_INPUT_TOKENS,
);
}
return Math.max(
Math.min(Math.floor(triggerTokens / 2), SUMMARY_MAX_TRIM_TOKENS),
Math.min(SUMMARY_MIN_TRIM_TOKENS, triggerTokens),
Expand All @@ -40,18 +63,38 @@ export function shouldSummarizeForEstimatedTokens(estimatedTokens: number, conte
}

/**
* LangChain's `summarizationMiddleware` swallows summary-model failures:
* `createSummary` catches the error and returns `"Error generating summary:
* ${e}"` (or "…: Invalid response format") as if it were the summary, and the
* middleware then commits the trim — permanently replacing the trimmed turns
* with an error stub in the model's context (#435, observed in a real thread
* where a mid-stream network error became the entire "memory" of 195
* messages). The summary message content is always
* `${SUMMARY_PREFIX}\n\n<createSummary result>`, so a failed one is
* identified by its exact prefix.
* Stub bodies LangChain's `createSummary` returns in place of a summary:
* - `"Error generating summary: ${e}"` (or "…: Invalid response format") — it
* catches summary-model failures and returns this as if it were the summary,
* and the middleware then commits the trim, permanently replacing the trimmed
* turns with an error stub (#435, observed in a real thread where a mid-stream
* network error became the entire "memory" of 195 messages);
* - `"Previous conversation was too long to summarize."` — the trim left nothing;
* - an empty string — the model answered with no text.
* The summary message content is always `${SUMMARY_PREFIX}\n\n<createSummary
* result>`, so a failed one is identified by what follows the prefix.
*/
export function isFailedSummaryContent(content: unknown): boolean {
return typeof content === "string" && content.startsWith(`${SUMMARY_PREFIX}\n\nError generating summary:`);
if (typeof content !== "string") return false;
const head = `${SUMMARY_PREFIX}\n\n`;
if (!content.startsWith(head)) return false;
const body = content.slice(head.length).trim();
return (
body.length === 0 ||
body.startsWith("Error generating summary:") ||
body === "Previous conversation was too long to summarize."
);
}

function isSummaryMessage(msg: unknown): msg is { additional_kwargs: Record<string, unknown>; content?: unknown } {
const message = msg as { additional_kwargs?: Record<string, unknown> } | null;
return message?.additional_kwargs?.lc_source === "summarization";
}

function getUpdateMessages(update: unknown): unknown[] | null {
if (!update || typeof update !== "object") return null;
const messages = (update as { messages?: unknown }).messages;
return Array.isArray(messages) ? messages : null;
}

/**
Expand All @@ -60,13 +103,38 @@ export function isFailedSummaryContent(content: unknown): boolean {
* summary `HumanMessage`, tagged `lc_source: "summarization"`.
*/
export function isFailedSummaryUpdate(update: unknown): boolean {
if (!update || typeof update !== "object") return false;
const messages = (update as { messages?: unknown }).messages;
if (!Array.isArray(messages)) return false;
return messages.some((msg) => {
const message = msg as { additional_kwargs?: Record<string, unknown>; content?: unknown } | null;
return message?.additional_kwargs?.lc_source === "summarization" && isFailedSummaryContent(message.content);
});
const messages = getUpdateMessages(update);
if (!messages) return false;
return messages.some((msg) => isSummaryMessage(msg) && isFailedSummaryContent(msg.content));
}

/** The messages a successful summarization update replaced with its summary. */
function getSummarizedMessages(state: unknown, updateMessages: unknown[]): BaseMessage[] {
const stateMessages = (state as { messages?: unknown } | null)?.messages;
if (!Array.isArray(stateMessages)) return [];
// The update is [RemoveMessage(all), summary, ...preserved]; everything in
// the old state that is not preserved was summarized.
const preservedIds = new Set(
updateMessages
.filter((msg) => !isSummaryMessage(msg))
.map((msg) => (msg as { id?: unknown }).id)
.filter((id) => typeof id === "string"),
);
return (stateMessages as BaseMessage[]).filter((msg) => !msg.id || !preservedIds.has(msg.id));
}

export interface SummarizationGuardOptions {
/** The middleware's `tokenCounter`, used to measure what was summarized. */
tokenCounter?: (messages: BaseMessage[]) => number | Promise<number>;
/** The middleware's `trimTokensToSummarize`. */
trimTokensToSummarize?: number;
/**
* Called when the history being replaced was larger than the summarizer's
* budget, so its oldest part was dropped instead of summarized.
*/
onHistoryDropped?: (info: { summarizedTokens: number; budgetTokens: number; droppedTokens: number }) => void;
/** Called once when summarization has failed this many turns in a row. */
onRepeatedFailure?: (consecutiveFailures: number) => void;
}

/**
Expand All @@ -75,20 +143,72 @@ export function isFailedSummaryUpdate(update: unknown): boolean {
* untrimmed history (one turn over the trigger ratio is harmless) and
* summarization simply retries on the next turn. Success and no-op updates
* pass through untouched.
*
* Failures are counted across turns, per thread (the runnable is cached per
* agent config and shared by every chat, so a single counter would mix
* chats); after `SUMMARY_FAILURE_NOTICE_THRESHOLD` in a row in one thread
* `onRepeatedFailure` fires once, since by then that thread's history keeps
* growing toward the provider's limit. A
* successful summary is measured against the trim budget and reports any
* history that was dropped unsummarized.
*/
export function guardSummarizationFailure<T extends { beforeModel?: unknown }>(middleware: T): T {
export function guardSummarizationFailure<T extends { beforeModel?: unknown }>(
middleware: T,
options: SummarizationGuardOptions = {},
): T {
const inner = middleware.beforeModel;
if (typeof inner !== "function") return middleware;

const failuresByThread = new Map<string, number>();
middleware.beforeModel = async (...args: unknown[]) => {
const update = await inner.apply(middleware, args);
const threadId = getThreadId(args[1]);
if (isFailedSummaryUpdate(update)) {
const consecutiveFailures = (failuresByThread.get(threadId) ?? 0) + 1;
failuresByThread.set(threadId, consecutiveFailures);
const summary = getUpdateMessages(update)?.find(isSummaryMessage)?.content;
Logger.warn(
"[Agent] Summary generation failed — keeping the full history this turn; summarization retries next turn.",
`[Agent] Summary generation failed (${consecutiveFailures} in a row) — keeping the full history this turn; summarization retries next turn.`,
summary,
);
if (consecutiveFailures === SUMMARY_FAILURE_NOTICE_THRESHOLD) {
options.onRepeatedFailure?.(consecutiveFailures);
}
return undefined;
}

const updateMessages = getUpdateMessages(update);
if (updateMessages?.some(isSummaryMessage)) {
failuresByThread.delete(threadId);
await reportDroppedHistory(args[0], updateMessages, options);
}
return update;
};
return middleware;
}

/** The run's thread id from the middleware runtime (`configurable.thread_id`). */
function getThreadId(runtime: unknown): string {
const threadId = (runtime as { configurable?: { thread_id?: unknown } } | null)?.configurable?.thread_id;
return typeof threadId === "string" ? threadId : "";
}

async function reportDroppedHistory(
state: unknown,
updateMessages: unknown[],
options: SummarizationGuardOptions,
): Promise<void> {
const { tokenCounter, trimTokensToSummarize: budgetTokens } = options;
if (!tokenCounter || budgetTokens === undefined) return;
try {
const summarizedTokens = await tokenCounter(getSummarizedMessages(state, updateMessages));
if (summarizedTokens <= budgetTokens) return;
const droppedTokens = summarizedTokens - budgetTokens;
Logger.warn(
`[Agent] Summarized ~${budgetTokens} of ~${summarizedTokens} tokens of history; the oldest ~${droppedTokens} did not fit the summarization model's budget and were dropped.`,
);
options.onHistoryDropped?.({ summarizedTokens, budgetTokens, droppedTokens });
} catch (err) {
Logger.debug("[Agent] Could not measure summarized history", err);
}
}
Loading
Loading