mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(agents): clean sessions_yield transcript suffix (#113190)
* [AI] fix(agents): strip trailing assistant messages after sessions_yield artifact removal * fix(ci): resolve check-lint and check-test-types failures * fix(agents): simplify sessions_yield cleanup (#113190) * fix(agents): separate sessions_yield cleanup budgets (#113190) (thanks @SunnyShu0925) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AssistantMessage, ToolResultMessage, UserMessage } from "../../../llm/types.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import { SessionManager } from "../../sessions/session-manager.js";
|
||||
import { stripSessionsYieldArtifacts } from "./attempt.sessions-yield.js";
|
||||
|
||||
const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt";
|
||||
|
||||
function makeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "response" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolResultMessage(): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "sessions_spawn",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserMessage(): UserMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "continue" }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeYieldInterruptMessage(): AgentMessage {
|
||||
return {
|
||||
role: "custom",
|
||||
customType: SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE,
|
||||
content: "[sessions_yield interrupt]",
|
||||
display: false,
|
||||
details: { source: "sessions_yield" },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(messages: AgentMessage[], sessionManager = SessionManager.inMemory()) {
|
||||
return {
|
||||
messages,
|
||||
agent: { state: { messages: [...messages] } },
|
||||
sessionManager,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripSessionsYieldArtifacts", () => {
|
||||
it("removes the full non-continuable yield suffix", () => {
|
||||
const toolResult = makeToolResultMessage();
|
||||
const session = buildSession([
|
||||
toolResult,
|
||||
makeAssistantMessage({ content: [{ type: "text", text: "work 1" }] }),
|
||||
makeAssistantMessage({ content: [{ type: "text", text: "work 2" }] }),
|
||||
makeAssistantMessage({ stopReason: "aborted" }),
|
||||
makeYieldInterruptMessage(),
|
||||
]);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
expect(session.agent.state.messages).toEqual([toolResult]);
|
||||
});
|
||||
|
||||
it("leaves a continuable suffix unchanged", () => {
|
||||
const messages = [makeToolResultMessage(), makeUserMessage()];
|
||||
const session = buildSession(messages);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
expect(session.agent.state.messages).toEqual(messages);
|
||||
});
|
||||
|
||||
it("strips an assistant tail after synthetic artifacts have already settled", () => {
|
||||
const toolResult = makeToolResultMessage();
|
||||
const session = buildSession([toolResult, makeAssistantMessage()]);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
expect(session.agent.state.messages).toEqual([toolResult]);
|
||||
});
|
||||
|
||||
it("caps persisted assistant cleanup when persistence lacks the interrupt marker", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(makeToolResultMessage());
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
sessionManager.appendMessage(
|
||||
makeAssistantMessage({ content: [{ type: "text", text: `persisted ${index}` }] }),
|
||||
);
|
||||
}
|
||||
const session = buildSession(
|
||||
[
|
||||
makeToolResultMessage(),
|
||||
makeAssistantMessage(),
|
||||
makeAssistantMessage({ stopReason: "aborted" }),
|
||||
makeYieldInterruptMessage(),
|
||||
],
|
||||
sessionManager,
|
||||
);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
const branch = sessionManager.getBranch();
|
||||
expect(
|
||||
branch.filter((entry) => entry.type === "message" && entry.message.role === "assistant"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
branch.some(
|
||||
(entry) =>
|
||||
entry.type === "custom_message" &&
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a persisted interrupt marker without consuming the assistant budget", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(makeToolResultMessage());
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
sessionManager.appendMessage(
|
||||
makeAssistantMessage({ content: [{ type: "text", text: `persisted ${index}` }] }),
|
||||
);
|
||||
}
|
||||
sessionManager.appendCustomMessageEntry(
|
||||
SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE,
|
||||
"[sessions_yield interrupt]",
|
||||
false,
|
||||
);
|
||||
|
||||
const session = buildSession(
|
||||
[makeToolResultMessage(), makeAssistantMessage(), makeAssistantMessage()],
|
||||
sessionManager,
|
||||
);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
const branch = sessionManager.getBranch();
|
||||
expect(
|
||||
branch.filter((entry) => entry.type === "message" && entry.message.role === "assistant"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
branch.some(
|
||||
(entry) =>
|
||||
entry.type === "custom_message" &&
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves trailing transcript metadata", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(makeToolResultMessage());
|
||||
sessionManager.appendMessage(makeAssistantMessage({ stopReason: "aborted" }));
|
||||
sessionManager.appendCustomMessageEntry(
|
||||
SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE,
|
||||
"[sessions_yield interrupt]",
|
||||
false,
|
||||
);
|
||||
sessionManager.appendCustomEntry("plugin-state", { enabled: true });
|
||||
|
||||
const session = buildSession(
|
||||
[
|
||||
makeToolResultMessage(),
|
||||
makeAssistantMessage({ stopReason: "aborted" }),
|
||||
makeYieldInterruptMessage(),
|
||||
],
|
||||
sessionManager,
|
||||
);
|
||||
|
||||
stripSessionsYieldArtifacts(session);
|
||||
|
||||
const branch = sessionManager.getBranch();
|
||||
expect(
|
||||
branch.some((entry) => entry.type === "custom" && entry.customType === "plugin-state"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
branch.some(
|
||||
(entry) =>
|
||||
(entry.type === "message" && entry.message.role === "assistant") ||
|
||||
(entry.type === "custom_message" &&
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import type { SessionManager } from "../../sessions/index.js";
|
||||
/**
|
||||
* Handles sessions-yield interruption, persistence, and artifact cleanup.
|
||||
*/
|
||||
@@ -171,74 +172,58 @@ export async function persistSessionsYieldContextMessage(
|
||||
}
|
||||
|
||||
// Remove the synthetic yield interrupt + aborted assistant entry from the live transcript.
|
||||
// After strip, the transcript must end with a non-assistant role so subagent
|
||||
// completion auto-announce can inject a continuation turn.
|
||||
export function stripSessionsYieldArtifacts(activeSession: {
|
||||
messages: AgentMessage[];
|
||||
agent: { state: { messages: AgentMessage[] } };
|
||||
sessionManager?: unknown;
|
||||
sessionManager: Pick<SessionManager, "removeTrailingEntries">;
|
||||
}) {
|
||||
const originalLength = activeSession.messages.length;
|
||||
const strippedMessages = activeSession.messages.slice();
|
||||
|
||||
// The tool-calling assistant turn and synthetic abort artifacts form one
|
||||
// non-continuable suffix after sessions_yield.
|
||||
while (strippedMessages.length > 0) {
|
||||
const last = strippedMessages.at(-1) as
|
||||
| AgentMessage
|
||||
| { role?: string; customType?: string; stopReason?: string };
|
||||
if (last?.role === "assistant" && "stopReason" in last && last.stopReason === "aborted") {
|
||||
strippedMessages.pop();
|
||||
continue;
|
||||
const last = strippedMessages.at(-1);
|
||||
const removable =
|
||||
last?.role === "assistant" ||
|
||||
(last?.role === "custom" && last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE);
|
||||
if (!removable) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
last?.role === "custom" &&
|
||||
"customType" in last &&
|
||||
last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE
|
||||
) {
|
||||
strippedMessages.pop();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (strippedMessages.length !== activeSession.messages.length) {
|
||||
activeSession.agent.state.messages = strippedMessages;
|
||||
strippedMessages.pop();
|
||||
}
|
||||
|
||||
const sessionManager = activeSession.sessionManager as
|
||||
| {
|
||||
removeTrailingEntries?: (
|
||||
predicate: (entry: {
|
||||
type?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
stopReason?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
customType?: string;
|
||||
}) => boolean,
|
||||
options?: {
|
||||
preserveTrailing?: (entry: {
|
||||
type?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
}) => boolean;
|
||||
},
|
||||
) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (typeof sessionManager?.removeTrailingEntries !== "function") {
|
||||
const removedMessages = activeSession.messages.slice(strippedMessages.length);
|
||||
if (removedMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionManager.removeTrailingEntries(
|
||||
activeSession.agent.state.messages = strippedMessages;
|
||||
|
||||
// The interrupt marker can settle independently in live and persisted state.
|
||||
// Only assistant removals need the live-suffix cap to prevent data loss.
|
||||
let remainingAssistantCount = removedMessages.filter(
|
||||
(message) => message.role === "assistant",
|
||||
).length;
|
||||
activeSession.sessionManager.removeTrailingEntries(
|
||||
(entry) => {
|
||||
const isYieldAbortAssistant =
|
||||
entry.type === "message" &&
|
||||
entry.message?.role === "assistant" &&
|
||||
entry.message?.stopReason === "aborted";
|
||||
const isYieldInterruptMessage =
|
||||
if (
|
||||
entry.type === "custom_message" &&
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
|
||||
return isYieldAbortAssistant || isYieldInterruptMessage;
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
entry.type !== "message" ||
|
||||
entry.message.role !== "assistant" ||
|
||||
remainingAssistantCount === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
remainingAssistantCount -= 1;
|
||||
return true;
|
||||
},
|
||||
{
|
||||
preserveTrailing: (entry) =>
|
||||
|
||||
Reference in New Issue
Block a user