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: 6 additions & 0 deletions .changeset/truncate-model-tool-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agents": patch
"@cloudflare/think": patch
---

Keep older tool outputs valid for `toModelOutput` and provider-executed tools. Think now truncates older tool results after `convertToModelMessages` instead of rewriting the stored output, so a validating `toModelOutput` no longer throws once a large result ages past the recent-message window, and provider-executed results such as Anthropic web search are replayed intact. `truncateOlderMessages` skips provider-executed outputs, accepts `toolOutputs: false`, and the new `truncateOlderToolResults` helper truncates converted model messages (#2014).
2 changes: 1 addition & 1 deletion docs/agents/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ await session.addCompaction(summary, fromMessageId, toMessageId);

Sessions stamps each message with a token estimate when the row is written. `compactAfter()` gates on that O(1) aggregate and never reads the transcript to decide whether to compact. Auto-compaction failures are non-fatal: they log, emit `session:error`, and leave the transcript alone.

To trim a transcript before handing it to a model, `truncateOlderMessages` is exported from [`agents/chat`](./chat-agents.md), not from `agents/sessions`.
To trim a transcript before handing it to a model, `truncateOlderMessages` is exported from [`agents/chat`](./chat-agents.md), not from `agents/sessions`. If your tools define `toModelOutput`, a truncated tool output may no longer match the tool's output schema. In that case, pass `toolOutputs: false` to `truncateOlderMessages` and call `truncateOlderToolResults` on the result of `convertToModelMessages`, which truncates what the model reads after `toModelOutput` has run. Provider-executed tool outputs are never truncated.

## Large messages

Expand Down
4 changes: 3 additions & 1 deletion packages/agents/src/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,5 +300,7 @@ export type {
} from "./lifecycle";
export {
truncateOlderMessages,
type TruncateOptions
truncateOlderToolResults,
type TruncateOptions,
type TruncateToolResultsOptions
} from "./truncate-older-messages";
136 changes: 131 additions & 5 deletions packages/agents/src/chat/truncate-older-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
* Read-time context truncation.
*
* Truncates older tool outputs and long text before sending to the LLM.
* Structured tool outputs keep their container shape so tool-specific
* `toModelOutput` handlers can safely replay older results.
* Does NOT mutate stored messages — operates on a copy.
*
* Truncating a UI tool output in place can still break a tool's declared
* output schema (markers, dropped array items, shortened strings), which a
* validating `toModelOutput` then rejects on every replay. Callers that pass
* `tools` to `convertToModelMessages` should set `toolOutputs: false` here and
* truncate the converted results with {@link truncateOlderToolResults}.
*/

import { truncateToolOutput } from "./tool-output-truncation";
import type { ModelMessage } from "ai";
import { truncatedSuffix, truncateToolOutput } from "./tool-output-truncation";
import type { SessionMessage } from "../sessions/types";

export interface TruncateOptions {
Expand All @@ -17,6 +22,12 @@ export interface TruncateOptions {
maxToolOutputChars?: number;
/** Max chars for text parts in older messages (default: 10000) */
maxTextChars?: number;
/**
* Truncate tool outputs in older messages (default: true). Set to `false`
* to leave them intact and truncate the converted model messages with
* {@link truncateOlderToolResults} instead.
*/
toolOutputs?: boolean;
}

/**
Expand All @@ -26,6 +37,8 @@ export interface TruncateOptions {
* Recent messages (last `keepRecent`) are left intact.
* Older messages get tool outputs and long text truncated. Structured tool
* outputs are truncated in place instead of being replaced by raw strings.
* Provider-executed tool outputs are never truncated: the provider parses
* them against its own schema when they are replayed.
*
* Use in assembleContext() before sending to the LLM:
* ```typescript
Expand All @@ -43,6 +56,7 @@ export function truncateOlderMessages(
const keepRecent = options?.keepRecent ?? 4;
const maxToolOutput = options?.maxToolOutputChars ?? 500;
const maxText = options?.maxTextChars ?? 10000;
const truncateToolOutputs = options?.toolOutputs ?? true;

if (messages.length <= keepRecent) return messages;

Expand All @@ -61,8 +75,10 @@ export function truncateOlderMessages(
const truncatedParts = msg.parts.map((part) => {
// Truncate tool outputs
if (
(part.type.startsWith("tool-") || part.type === "dynamic-tool") &&
"output" in part
truncateToolOutputs &&
isToolPart(part) &&
"output" in part &&
!isProviderExecuted(part)
) {
const output = (part as { output?: unknown }).output;
if (output !== undefined) {
Expand Down Expand Up @@ -99,3 +115,113 @@ export function truncateOlderMessages(

return result;
}

export interface TruncateToolResultsOptions {
/** Number of recent UI messages whose tool results stay intact (default: 4) */
keepRecent?: number;
/** Max chars for each older tool result (default: 500) */
maxToolOutputChars?: number;
}

/**
* Truncate the tool results of older messages after `convertToModelMessages`.
* `messages` are the UI messages that were converted, so "older" means the
* same messages {@link truncateOlderMessages} treats as older.
*
* The converted result is what the model reads, after any `toModelOutput`, so
* no tool schema applies to it. Provider-executed results are left intact.
*/
export function truncateOlderToolResults<M extends ModelMessage>(
modelMessages: M[],
messages: readonly SessionMessage[],
options?: TruncateToolResultsOptions
): M[] {
const keepRecent = options?.keepRecent ?? 4;
const maxChars = options?.maxToolOutputChars ?? 500;
const cutoff = messages.length - keepRecent;
if (cutoff <= 0) return modelMessages;

const olderToolCallIds = new Set<string>();
for (const message of messages.slice(0, cutoff)) {
for (const part of message.parts) {
if (isToolPart(part) && !isProviderExecuted(part)) {
olderToolCallIds.add((part as { toolCallId: string }).toolCallId);
}
}
}
if (olderToolCallIds.size === 0) return modelMessages;

return modelMessages.map((message) => {
if (message.role !== "tool") return message;
let changed = false;
const content = message.content.map((part) => {
if (part.type !== "tool-result" || !olderToolCallIds.has(part.toolCallId))
return part;
const output = truncateModelOutput(part.output, maxChars);
if (output === part.output) return part;
changed = true;
return { ...part, output };
});
return changed ? { ...message, content } : message;
});
}

type ToolResultOutput = Extract<
Extract<ModelMessage, { role: "tool" }>["content"][number],
{ type: "tool-result" }
>["output"];

function truncateModelOutput(
output: ToolResultOutput,
maxChars: number
): ToolResultOutput {
switch (output.type) {
case "text":
case "error-text":
case "json":
case "error-json": {
const truncated = truncateToolOutput(output.value, maxChars);
return truncated.truncated
? ({ ...output, value: truncated.output } as ToolResultOutput)
: output;
}
case "content": {
const total = output.value.reduce(
(sum, item) => sum + (item.type === "text" ? item.text.length : 0),
0
);
if (total <= maxChars) return output;
// `maxChars` bounds the whole result, so text items share one budget,
// and room is kept for the marker so a dropped tail is never silent.
const suffix = truncatedSuffix(total);
let remaining = Math.max(0, maxChars - suffix.length);
let truncated = false;
type ContentItem = (typeof output.value)[number];
const value = output.value.flatMap((item): ContentItem[] => {
if (item.type !== "text") return [item];
if (truncated) return [];
if (item.text.length <= remaining) {
remaining -= item.text.length;
return [item];
}
truncated = true;
const text =
maxChars <= suffix.length
? suffix.slice(0, maxChars)
: `${item.text.slice(0, remaining)}${suffix}`;
return [{ ...item, text }];
});
return { ...output, value };
}
default:
return output;
}
}

function isToolPart(part: { type: string }): boolean {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}

function isProviderExecuted(part: object): boolean {
return (part as { providerExecuted?: unknown }).providerExecuted === true;
}
Loading
Loading