Skip to content
Closed
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
103 changes: 96 additions & 7 deletions packages/agent-core/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ import {
type AssistantMessage,
type Context,
EventStream,
type ImageContent,
type TextContent,
type ToolResultMessage,
validateToolArguments,
} from "@step-harness/providers";
import {
truncateStringToBytesFromEnd,
truncateStringToBytesFromStart,
utf8ByteLength,
} from "./harness/utils/truncate.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AgentContext,
Expand All @@ -25,6 +32,82 @@ import type {

export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;

/**
* Default cap on the combined byte size of text content blocks in a tool result,
* applied just before the result is turned into a `ToolResultMessage` and persisted
* to session history. Built-in tools already cap their own output well below this
* (see DEFAULT_MAX_BYTES in harness/utils/truncate.ts), so this default leaves
* built-in tool output unaffected; it exists to bound extension/MCP/custom tool
* results, which have no cap of their own otherwise.
*/
export const DEFAULT_MAX_TOOL_RESULT_BYTES = 128 * 1024; // 128KB

const TOOL_RESULT_HEAD_FRACTION = 0.8;

function buildToolResultElisionMarker(toolName: string, originalBytes: number, maxBytes: number): string {
return `\n\n[... tool result truncated: "${toolName}" returned ${originalBytes} bytes, exceeding the ${maxBytes}-byte cap. Showing the beginning and end; the middle is elided. Re-call ${toolName} with narrower arguments or pagination to see the rest. ...]\n\n`;
}

/**
* Caps the combined byte size of text content blocks in a tool result.
*
* This is the single chokepoint that bounds every tool result - built-in, extension,
* MCP, or custom - before it enters session history and gets re-sent on every
* subsequent turn. Only text blocks are measured/trimmed; image blocks pass through
* untouched.
*
* `maxToolResultBytes` semantics: `undefined` applies the default cap
* (`DEFAULT_MAX_TOOL_RESULT_BYTES`); `0` (or any other non-positive value)
* explicitly disables capping; a positive number uses that cap.
*/
function capToolResultContent(
content: (TextContent | ImageContent)[],
toolName: string,
maxToolResultBytes: number | undefined,
): (TextContent | ImageContent)[] {
const effectiveMaxBytes = maxToolResultBytes === undefined ? DEFAULT_MAX_TOOL_RESULT_BYTES : maxToolResultBytes;
if (!effectiveMaxBytes || effectiveMaxBytes <= 0) return content;

const textIndices: number[] = [];
let totalTextBytes = 0;
for (let i = 0; i < content.length; i++) {
const block = content[i];
if (block.type === "text") {
textIndices.push(i);
totalTextBytes += utf8ByteLength(block.text);
}
}
if (textIndices.length === 0 || totalTextBytes <= effectiveMaxBytes) {
return content;
}

const combinedText = textIndices.map((i) => (content[i] as TextContent).text).join("\n");
Comment on lines +71 to +84
const marker = buildToolResultElisionMarker(toolName, totalTextBytes, effectiveMaxBytes);
const markerBytes = utf8ByteLength(marker);
const budget = Math.max(0, effectiveMaxBytes - markerBytes);
const headBudget = Math.ceil(budget * TOOL_RESULT_HEAD_FRACTION);
const tailBudget = budget - headBudget;

const head = truncateStringToBytesFromStart(combinedText, headBudget);
const tail = tailBudget > 0 ? truncateStringToBytesFromEnd(combinedText, tailBudget) : "";
const cappedText = head + marker + tail;
Comment on lines +86 to +93

const firstTextIndex = textIndices[0];
const result: (TextContent | ImageContent)[] = [];
for (let i = 0; i < content.length; i++) {
const block = content[i];
if (block.type !== "text") {
result.push(block);
continue;
}
if (i === firstTextIndex) {
result.push({ type: "text", text: cappedText });
}
// Other text blocks are dropped; their content is already folded into cappedText.
}
return result;
}

/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
Expand Down Expand Up @@ -242,7 +325,7 @@ async function runLoop(
// them all instead of executing potentially borked calls.
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
? await failToolCallsFromTruncatedMessage(toolCalls, config, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
Expand Down Expand Up @@ -417,6 +500,7 @@ async function streamAssistantResponse(
*/
async function failToolCallsFromTruncatedMessage(
toolCalls: AgentToolCall[],
config: AgentLoopConfig,
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const messages: ToolResultMessage[] = [];
Expand All @@ -435,7 +519,7 @@ async function failToolCallsFromTruncatedMessage(
isError: true,
};
await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
const toolResultMessage = createToolResultMessage(finalized, config.maxToolResultBytes);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
Expand Down Expand Up @@ -507,7 +591,7 @@ async function executeToolCallsSequential(
}

await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
const toolResultMessage = createToolResultMessage(finalized, config.maxToolResultBytes);
await emitToolResultMessage(toolResultMessage, emit);
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
Expand Down Expand Up @@ -579,7 +663,7 @@ async function executeToolCallsParallel(
);
const messages: ToolResultMessage[] = [];
for (const finalized of orderedFinalizedCalls) {
const toolResultMessage = createToolResultMessage(finalized);
const toolResultMessage = createToolResultMessage(finalized, config.maxToolResultBytes);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
Expand Down Expand Up @@ -811,14 +895,19 @@ async function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: A
});
}

function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
function createToolResultMessage(
finalized: FinalizedToolCallOutcome,
maxToolResultBytes: number | undefined,
): ToolResultMessage {
return {
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
// Untyped tools (JS extensions) can return results without content; normalize
// so the null never enters session history or provider payloads.
content: finalized.result.content ?? [],
// so the null never enters session history or provider payloads. Cap the
// resulting text so oversized extension/MCP/custom results never enter
// session history or get re-sent on every subsequent turn.
content: capToolResultContent(finalized.result.content ?? [], finalized.toolCall.name, maxToolResultBytes),
details: finalized.result.details,
usage: finalized.result.usage,
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export interface AgentOptions {
transport?: Transport;
maxRetryDelayMs?: number;
toolExecution?: ToolExecutionMode;
/** See {@link AgentLoopConfig.maxToolResultBytes}. */
maxToolResultBytes?: number;
}

class PendingMessageQueue {
Expand Down Expand Up @@ -212,6 +214,8 @@ export class Agent {
public maxRetryDelayMs?: number;
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
public toolExecution: ToolExecutionMode;
/** See {@link AgentLoopConfig.maxToolResultBytes}. */
public maxToolResultBytes?: number;

constructor(options: AgentOptions) {
// Older compiled consumers may omit options or streamFn even though the current API requires them.
Expand All @@ -235,6 +239,7 @@ export class Agent {
this.transport = runtimeOptions.transport ?? "auto";
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
this.maxToolResultBytes = runtimeOptions.maxToolResultBytes;
}

/**
Expand Down Expand Up @@ -457,6 +462,7 @@ export class Agent {
toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall,
maxToolResultBytes: this.maxToolResultBytes,
shouldStopAfterTurn: shouldStopAfterTurn
? async (context) => await shouldStopAfterTurn(context, this.signal)
: undefined,
Expand Down
45 changes: 43 additions & 2 deletions packages/agent-core/src/harness/utils/truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface RuntimeBuffer {
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
const nonAsciiPattern = /[^\x00-\x7f]/;

function utf8ByteLength(content: string): number {
export function utf8ByteLength(content: string): number {
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");

const firstNonAscii = content.search(nonAsciiPattern);
Expand Down Expand Up @@ -294,11 +294,52 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
};
}

/**
* Truncate a string to fit within a byte limit (from the start).
* Handles multi-byte UTF-8 characters correctly; never splits a surrogate pair.
*/
export function truncateStringToBytesFromStart(str: string, maxBytes: number): string {
if (maxBytes <= 0) return "";

let outputBytes = 0;
let end = 0;
let needsReplacement = false;
for (let i = 0; i < str.length; ) {
const code = str.charCodeAt(i);
let characterEnd = i + 1;
let characterBytes: number;
let unpairedSurrogate = false;
if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
const next = str.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
characterEnd = i + 2;
characterBytes = 4;
} else {
characterBytes = 3;
unpairedSurrogate = true;
}
} else if (code >= 0xd800 && code <= 0xdfff) {
characterBytes = 3;
unpairedSurrogate = true;
} else {
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
}
if (outputBytes + characterBytes > maxBytes) break;
outputBytes += characterBytes;
end = characterEnd;
needsReplacement ||= unpairedSurrogate;
i = characterEnd;
}

const output = str.slice(0, end);
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
}

/**
* Truncate a string to fit within a byte limit (from the end).
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
export function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
if (maxBytes <= 0) return "";

let outputBytes = 0;
Expand Down
19 changes: 19 additions & 0 deletions packages/agent-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,25 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* The hook receives the agent abort signal and is responsible for honoring it.
*/
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;

/**
* Caps the combined byte size of text content blocks in every tool result,
* applied after `afterToolCall` and just before the result becomes a
* `ToolResultMessage` that enters session history and gets re-sent on every
* subsequent turn. This is the one place that bounds ALL tool results -
* built-in, extension, MCP, or custom - regardless of whether the tool
* itself truncates its own output.
*
* When exceeded, a head (and tail) of the combined text is kept and an
* elision marker is inserted stating the original size, the cap, the tool
* name, and instructing the model to re-call with narrower arguments or
* pagination. Image content blocks are never touched.
*
* `undefined` applies the default cap of 128KB, comfortably above the
* built-in per-tool caps (50KB), so built-in tool output is unaffected.
* Set explicitly to `0` (or any non-positive value) to disable capping.
*/
maxToolResultBytes?: number;
}

/**
Expand Down
Loading
Loading