mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 05:50:23 +00:00
271 lines
9.6 KiB
TypeScript
271 lines
9.6 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import type {
|
|
PluginHookBeforeMessageWriteEvent,
|
|
PluginHookBeforeMessageWriteResult,
|
|
} from "../plugins/types.js";
|
|
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
|
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
|
import { formatContextLimitTruncationNotice } from "./pi-embedded-runner/tool-result-context-guard.js";
|
|
import {
|
|
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
|
|
truncateToolResultMessage,
|
|
} from "./pi-embedded-runner/tool-result-truncation.js";
|
|
import {
|
|
getRawSessionAppendMessage,
|
|
setRawSessionAppendMessage,
|
|
} from "./session-raw-append-message.js";
|
|
import { createPendingToolCallState } from "./session-tool-result-state.js";
|
|
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
|
|
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
|
|
|
|
/**
|
|
* Truncate oversized text content blocks in a tool result message.
|
|
* Returns the original message if under the limit, or a new message with
|
|
* truncated text blocks otherwise.
|
|
*/
|
|
function capToolResultSize(msg: AgentMessage): AgentMessage {
|
|
if ((msg as { role?: string }).role !== "toolResult") {
|
|
return msg;
|
|
}
|
|
return truncateToolResultMessage(msg, DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, {
|
|
suffix: (truncatedChars) => formatContextLimitTruncationNotice(truncatedChars),
|
|
minKeepChars: 2_000,
|
|
});
|
|
}
|
|
|
|
function normalizePersistedToolResultName(
|
|
message: AgentMessage,
|
|
fallbackName?: string,
|
|
): AgentMessage {
|
|
if ((message as { role?: unknown }).role !== "toolResult") {
|
|
return message;
|
|
}
|
|
const toolResult = message as Extract<AgentMessage, { role: "toolResult" }>;
|
|
const rawToolName = (toolResult as { toolName?: unknown }).toolName;
|
|
const normalizedToolName = normalizeOptionalString(rawToolName);
|
|
if (normalizedToolName) {
|
|
if (rawToolName === normalizedToolName) {
|
|
return toolResult;
|
|
}
|
|
return { ...toolResult, toolName: normalizedToolName };
|
|
}
|
|
|
|
const normalizedFallback = normalizeOptionalString(fallbackName);
|
|
if (normalizedFallback) {
|
|
return { ...toolResult, toolName: normalizedFallback };
|
|
}
|
|
|
|
if (typeof rawToolName === "string") {
|
|
return { ...toolResult, toolName: "unknown" };
|
|
}
|
|
return toolResult;
|
|
}
|
|
|
|
export { getRawSessionAppendMessage };
|
|
|
|
export function installSessionToolResultGuard(
|
|
sessionManager: SessionManager,
|
|
opts?: {
|
|
/** Optional session key for transcript update broadcasts. */
|
|
sessionKey?: string;
|
|
/**
|
|
* Optional transform applied to any message before persistence.
|
|
*/
|
|
transformMessageForPersistence?: (message: AgentMessage) => AgentMessage;
|
|
/**
|
|
* Optional, synchronous transform applied to toolResult messages *before* they are
|
|
* persisted to the session transcript.
|
|
*/
|
|
transformToolResultForPersistence?: (
|
|
message: AgentMessage,
|
|
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
|
) => AgentMessage;
|
|
/**
|
|
* Whether to synthesize missing tool results to satisfy strict providers.
|
|
* Defaults to true.
|
|
*/
|
|
allowSyntheticToolResults?: boolean;
|
|
/**
|
|
* Optional set/list of tool names accepted for assistant toolCall/toolUse blocks.
|
|
* When set, tool calls with unknown names are dropped before persistence.
|
|
*/
|
|
allowedToolNames?: Iterable<string>;
|
|
/**
|
|
* Synchronous hook invoked before any message is written to the session JSONL.
|
|
* If the hook returns { block: true }, the message is silently dropped.
|
|
* If it returns { message }, the modified message is written instead.
|
|
*/
|
|
beforeMessageWriteHook?: (
|
|
event: PluginHookBeforeMessageWriteEvent,
|
|
) => PluginHookBeforeMessageWriteResult | undefined;
|
|
},
|
|
): {
|
|
flushPendingToolResults: () => void;
|
|
clearPendingToolResults: () => void;
|
|
getPendingIds: () => string[];
|
|
} {
|
|
const originalAppend = getRawSessionAppendMessage(sessionManager);
|
|
setRawSessionAppendMessage(sessionManager, originalAppend);
|
|
const pendingState = createPendingToolCallState();
|
|
const persistMessage = (message: AgentMessage) => {
|
|
const transformer = opts?.transformMessageForPersistence;
|
|
return transformer ? transformer(message) : message;
|
|
};
|
|
|
|
const persistToolResult = (
|
|
message: AgentMessage,
|
|
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
|
) => {
|
|
const transformer = opts?.transformToolResultForPersistence;
|
|
return transformer ? transformer(message, meta) : message;
|
|
};
|
|
|
|
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
|
|
const beforeWrite = opts?.beforeMessageWriteHook;
|
|
|
|
/**
|
|
* Run the before_message_write hook. Returns the (possibly modified) message,
|
|
* or null if the message should be blocked.
|
|
*/
|
|
const applyBeforeWriteHook = (msg: AgentMessage): AgentMessage | null => {
|
|
if (!beforeWrite) {
|
|
return msg;
|
|
}
|
|
const result = beforeWrite({ message: msg });
|
|
if (result?.block) {
|
|
return null;
|
|
}
|
|
if (result?.message) {
|
|
return result.message;
|
|
}
|
|
return msg;
|
|
};
|
|
|
|
const flushPendingToolResults = () => {
|
|
if (pendingState.size() === 0) {
|
|
return;
|
|
}
|
|
if (allowSyntheticToolResults) {
|
|
for (const [id, name] of pendingState.entries()) {
|
|
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
|
const flushed = applyBeforeWriteHook(
|
|
persistToolResult(persistMessage(synthetic), {
|
|
toolCallId: id,
|
|
toolName: name,
|
|
isSynthetic: true,
|
|
}),
|
|
);
|
|
if (flushed) {
|
|
originalAppend(flushed as never);
|
|
}
|
|
}
|
|
}
|
|
pendingState.clear();
|
|
};
|
|
|
|
const clearPendingToolResults = () => {
|
|
pendingState.clear();
|
|
};
|
|
|
|
const guardedAppend = (message: AgentMessage) => {
|
|
let nextMessage = message;
|
|
const role = (message as { role?: unknown }).role;
|
|
if (role === "assistant") {
|
|
const sanitized = sanitizeToolCallInputs([message], {
|
|
allowedToolNames: opts?.allowedToolNames,
|
|
});
|
|
if (sanitized.length === 0) {
|
|
if (pendingState.shouldFlushForSanitizedDrop()) {
|
|
flushPendingToolResults();
|
|
}
|
|
return undefined;
|
|
}
|
|
nextMessage = sanitized[0];
|
|
}
|
|
const nextRole = (nextMessage as { role?: unknown }).role;
|
|
|
|
if (nextRole === "toolResult") {
|
|
const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>);
|
|
const toolName = id ? pendingState.getToolName(id) : undefined;
|
|
if (id) {
|
|
pendingState.delete(id);
|
|
}
|
|
const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName);
|
|
// Apply hard size cap before persistence to prevent oversized tool results
|
|
// from consuming the entire context window on subsequent LLM calls.
|
|
const capped = capToolResultSize(persistMessage(normalizedToolResult));
|
|
const persisted = applyBeforeWriteHook(
|
|
persistToolResult(capped, {
|
|
toolCallId: id ?? undefined,
|
|
toolName,
|
|
isSynthetic: false,
|
|
}),
|
|
);
|
|
if (!persisted) {
|
|
return undefined;
|
|
}
|
|
return originalAppend(persisted as never);
|
|
}
|
|
|
|
// Skip tool call extraction for aborted/errored assistant messages.
|
|
// When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
|
|
// and should not have synthetic tool_results created. Creating synthetic results
|
|
// for incomplete tool calls causes API 400 errors:
|
|
// "unexpected tool_use_id found in tool_result blocks"
|
|
// This matches the behavior in repairToolUseResultPairing (session-transcript-repair.ts)
|
|
const stopReason = (nextMessage as { stopReason?: string }).stopReason;
|
|
const toolCalls =
|
|
nextRole === "assistant" && stopReason !== "aborted" && stopReason !== "error"
|
|
? extractToolCallsFromAssistant(nextMessage as Extract<AgentMessage, { role: "assistant" }>)
|
|
: [];
|
|
|
|
// Always clear pending tool call state before appending non-tool-result messages.
|
|
// flushPendingToolResults() only inserts synthetic results when allowSyntheticToolResults
|
|
// is true; it always clears the pending map. Without this, providers that disable
|
|
// synthetic results (e.g. OpenAI) accumulate stale pending state when a user message
|
|
// interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript
|
|
// that cause API 400 errors on subsequent requests.
|
|
if (pendingState.shouldFlushBeforeNonToolResult(nextRole, toolCalls.length)) {
|
|
flushPendingToolResults();
|
|
}
|
|
// If new tool calls arrive while older ones are pending, flush the old ones first.
|
|
if (pendingState.shouldFlushBeforeNewToolCalls(toolCalls.length)) {
|
|
flushPendingToolResults();
|
|
}
|
|
|
|
const finalMessage = applyBeforeWriteHook(persistMessage(nextMessage));
|
|
if (!finalMessage) {
|
|
return undefined;
|
|
}
|
|
const result = originalAppend(finalMessage as never);
|
|
|
|
const sessionFile = (
|
|
sessionManager as { getSessionFile?: () => string | null }
|
|
).getSessionFile?.();
|
|
if (sessionFile) {
|
|
emitSessionTranscriptUpdate({
|
|
sessionFile,
|
|
sessionKey: opts?.sessionKey,
|
|
message: finalMessage,
|
|
messageId: typeof result === "string" ? result : undefined,
|
|
});
|
|
}
|
|
|
|
if (toolCalls.length > 0) {
|
|
pendingState.trackToolCalls(toolCalls);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
// Monkey-patch appendMessage with our guarded version.
|
|
sessionManager.appendMessage = guardedAppend as SessionManager["appendMessage"];
|
|
|
|
return {
|
|
flushPendingToolResults,
|
|
clearPendingToolResults,
|
|
getPendingIds: pendingState.getPendingIds,
|
|
};
|
|
}
|