fix(agents): drop orphan tool results

This commit is contained in:
mickobizzle 2026-01-28 15:58:43 -07:00
parent a7534dc223
commit 5db5dc2def
3 changed files with 30 additions and 0 deletions

View File

@ -18,6 +18,7 @@ export function guardSessionManager(
agentId?: string;
sessionKey?: string;
allowSyntheticToolResults?: boolean;
dropOrphanToolResults?: boolean;
},
): GuardedSessionManager {
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
@ -48,6 +49,7 @@ export function guardSessionManager(
const guard = installSessionToolResultGuard(sessionManager, {
transformToolResultForPersistence: transform,
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
dropOrphanToolResults: opts?.dropOrphanToolResults,
});
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
return sessionManager as GuardedSessionManager;

View File

@ -72,6 +72,25 @@ describe("installSessionToolResultGuard", () => {
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
});
it("drops orphan toolResult messages by default", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
sm.appendMessage({
role: "toolResult",
toolCallId: "orphan_call",
content: [{ type: "text", text: "no matching tool call" }],
isError: true,
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
expect(messages).toHaveLength(0);
});
it("preserves ordering with multiple tool calls and partial results", () => {
const sm = SessionManager.inMemory();
const guard = installSessionToolResultGuard(sm);

View File

@ -49,6 +49,11 @@ export function installSessionToolResultGuard(
* Defaults to true.
*/
allowSyntheticToolResults?: boolean;
/**
* Whether to drop toolResult messages that do not match a pending tool call.
* Defaults to true to avoid corrupting transcripts with orphan tool results.
*/
dropOrphanToolResults?: boolean;
},
): {
flushPendingToolResults: () => void;
@ -66,6 +71,7 @@ export function installSessionToolResultGuard(
};
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
const dropOrphanToolResults = opts?.dropOrphanToolResults ?? true;
const flushPendingToolResults = () => {
if (pending.size === 0) return;
@ -90,6 +96,9 @@ export function installSessionToolResultGuard(
if (role === "toolResult") {
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
const toolName = id ? pending.get(id) : undefined;
if (id && !pending.has(id) && dropOrphanToolResults) {
return undefined as never;
}
if (id) pending.delete(id);
return originalAppend(
persistToolResult(message, {