mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-30 01:08:26 +08:00
* fix(#4058): [Bug]: Codex memory plugin replays historical turns after resume or transcript compaction Fixes #4058 Ref: https://github.com/volcengine/OpenViking/issues/4058 * fix(codex): retire committed cursors and keep activity-based concurrency Preserving the transcript cursor after a commit stops the replay, but it also means nothing deletes state files any more: clearState() lost its last caller, so every codex session — including ones that never captured a turn — leaves a file behind, and listStates() reads all of them on every SessionStart. The sweep now retires cursor-only states in the same pass: a real cursor is kept for resume until OPENVIKING_CODEX_COMMITTED_TTL_MS (default 30 days, past the life of the codex rollout it indexes), and a state that never captured anything goes on the idle schedule, which is what the old sweep did with it. Releasing ovSessionId also wrote lastUpdatedAt, making a committed session look freshly active; saveState() takes touch:false so the field keeps meaning "last transcript activity" for both the active window and retention. Requiring a live ovSessionId to count as recently-active made the heuristic miss sessions PreCompact had just committed, which can still be running: the count is back on activity alone, and only a state with a live session is committed. Also name the shrink predicate: role === "user" covers tool results too (normalizeCaptureRole maps them onto the user role), so findLastHumanTurnIndex requires a text part, and the no-human-turn fallback to a full replay is now visible in the log instead of silent. --------- Co-authored-by: 7487 <1042653432@qq.com>
197 lines
5.7 KiB
JavaScript
197 lines
5.7 KiB
JavaScript
import {
|
|
extractCaptureTurns as extractSharedCaptureTurns,
|
|
} from "./shared/capture-utils.mjs";
|
|
|
|
export * from "./shared/capture-utils.mjs";
|
|
|
|
function mcpResultText(result) {
|
|
const ok = result?.Ok;
|
|
if (Array.isArray(ok?.content)) {
|
|
const text = ok.content
|
|
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
.map((part) => part.text)
|
|
.join("\n");
|
|
if (text) return text;
|
|
}
|
|
if (typeof ok === "string") return ok;
|
|
if (ok != null) return JSON.stringify(ok);
|
|
const error = result?.Err;
|
|
if (typeof error === "string") return error;
|
|
return error == null ? "" : JSON.stringify(error);
|
|
}
|
|
|
|
function deduplicateMcpFunctionEvents(rolloutEntries) {
|
|
const mcpCallIds = new Set(
|
|
(rolloutEntries || [])
|
|
.filter((entry) => entry?.payload?.type === "mcp_tool_call_end")
|
|
.map((entry) => entry.payload.call_id)
|
|
.filter(Boolean),
|
|
);
|
|
if (mcpCallIds.size === 0) return rolloutEntries || [];
|
|
|
|
const duplicatedTypes = new Set([
|
|
"function_call",
|
|
"function_call_output",
|
|
"custom_tool_call",
|
|
"custom_tool_call_output",
|
|
]);
|
|
return (rolloutEntries || []).filter((entry) => {
|
|
const payload = entry?.payload;
|
|
return !(
|
|
duplicatedTypes.has(payload?.type)
|
|
&& mcpCallIds.has(payload.call_id || payload.id)
|
|
);
|
|
});
|
|
}
|
|
|
|
function agentMessageText(payload) {
|
|
const content = Array.isArray(payload?.content) ? payload.content : [];
|
|
return content
|
|
.filter((part) => (
|
|
(part?.type === "input_text" || part?.type === "output_text" || part?.type === "text")
|
|
&& typeof part.text === "string"
|
|
))
|
|
.map((part) => part.text)
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
function normalizeCodexNativeToolEvents(rolloutEntries) {
|
|
return (rolloutEntries || []).map((entry) => {
|
|
const payload = entry?.payload;
|
|
if (entry?.type === "response_item" && payload?.type === "agent_message") {
|
|
const text = agentMessageText(payload);
|
|
const author = String(payload.author || "subagent");
|
|
const recipient = String(payload.recipient || "main");
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{
|
|
type: "output_text",
|
|
text: `[agent-message ${author} -> ${recipient}]${text ? `\n${text}` : ""}`,
|
|
}],
|
|
},
|
|
};
|
|
}
|
|
if (payload?.type === "sub_agent_activity") {
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "tool_result",
|
|
call_id: `subagent-activity:${payload.event_id}`,
|
|
name: "sub_agent_activity",
|
|
output: {
|
|
agent_thread_id: payload.agent_thread_id,
|
|
agent_path: payload.agent_path,
|
|
kind: payload.kind,
|
|
occurred_at_ms: payload.occurred_at_ms,
|
|
},
|
|
status: "completed",
|
|
},
|
|
};
|
|
}
|
|
if (payload?.type === "custom_tool_call") {
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "function_call",
|
|
call_id: payload.call_id || payload.id,
|
|
name: payload.name,
|
|
arguments: payload.input,
|
|
},
|
|
};
|
|
}
|
|
if (payload?.type === "custom_tool_call_output") {
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "function_call_output",
|
|
call_id: payload.call_id || payload.id,
|
|
output: payload.output,
|
|
},
|
|
};
|
|
}
|
|
if (payload?.type === "tool_search_call") {
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "function_call",
|
|
call_id: payload.call_id || payload.id,
|
|
name: "tool_search",
|
|
arguments: payload.arguments || {},
|
|
},
|
|
};
|
|
}
|
|
if (payload?.type === "tool_search_output") {
|
|
return {
|
|
...entry,
|
|
payload: {
|
|
type: "function_call_output",
|
|
call_id: payload.call_id || payload.id,
|
|
output: JSON.stringify({ tools: payload.tools || [] }),
|
|
},
|
|
};
|
|
}
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
function normalizeCodexMcpToolEvents(rolloutEntries) {
|
|
return (rolloutEntries || []).flatMap((entry) => {
|
|
const payload = entry?.payload;
|
|
if (payload?.type !== "mcp_tool_call_end") return [entry];
|
|
|
|
const callId = payload.call_id;
|
|
const toolName = payload.invocation?.tool;
|
|
if (!callId || !toolName) return [entry];
|
|
|
|
const call = {
|
|
payload: {
|
|
type: "function_call",
|
|
id: callId,
|
|
name: toolName,
|
|
arguments: payload.invocation?.arguments || {},
|
|
},
|
|
};
|
|
const output = mcpResultText(payload.result);
|
|
const completed = (
|
|
Object.prototype.hasOwnProperty.call(payload.result || {}, "Ok")
|
|
&& payload.result?.Ok?.isError !== true
|
|
);
|
|
const result = {
|
|
payload: {
|
|
type: "function_call_output",
|
|
call_id: callId,
|
|
...(completed ? { output } : { error: output || "MCP tool call failed" }),
|
|
},
|
|
};
|
|
return [call, result];
|
|
});
|
|
}
|
|
|
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
const deduplicated = deduplicateMcpFunctionEvents(rolloutEntries);
|
|
const normalized = normalizeCodexNativeToolEvents(deduplicated);
|
|
return extractSharedCaptureTurns(normalizeCodexMcpToolEvents(normalized), cfg);
|
|
}
|
|
|
|
/**
|
|
* Index of the last turn that came from a human prompt, or -1.
|
|
*
|
|
* `role === "user"` alone is not enough: normalizeCaptureRole() maps tool
|
|
* results onto the user role too, and those carry `tool` parts rather than
|
|
* `text` parts. Used by the post-compact shrink path to find where the current
|
|
* interaction starts.
|
|
*/
|
|
export function findLastHumanTurnIndex(turns) {
|
|
const list = Array.isArray(turns) ? turns : [];
|
|
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
const turn = list[i];
|
|
if (turn?.role !== "user") continue;
|
|
if (turn.parts?.some((part) => part?.type === "text")) return i;
|
|
}
|
|
return -1;
|
|
}
|