Compare commits

..

7 Commits

Author SHA1 Message Date
Omar Shahine
d5335b0743 fix(codex): honor normalized source routes 2026-06-24 19:27:03 -07:00
Omar Shahine
9f84eaa087 fix(codex): require delivered non-send telemetry 2026-06-24 19:19:52 -07:00
Omar Shahine
97264cb7cb fix(codex): reject alias-routed source replies 2026-06-24 19:16:07 -07:00
Omar Shahine
87d3d14ec8 fix(codex): require delivered reply receipts 2026-06-24 19:09:13 -07:00
Omar Shahine
dcb02431d6 fix(codex): account for source reply SDK surface 2026-06-24 18:59:29 -07:00
Omar Shahine
d94d2c8b35 fix(codex): accept numeric source message ids 2026-06-24 18:55:51 -07:00
Omar Shahine
4291b6b7b9 fix(codex): recognize message tool source replies 2026-06-24 18:55:50 -07:00
28 changed files with 990 additions and 1568 deletions

View File

@@ -1,2 +1,2 @@
ea7c5c6dc96594843238bdc8674e0f03041a61445d6e2d0ab82c30c9ce832f91 plugin-sdk-api-baseline.json
65282a8e00237c16745670e2583a289349be1dbd1a0d395789da9dceb1538cf9 plugin-sdk-api-baseline.jsonl
35b314075ff47453c5d57788861ca0c0e65d6a988b549ab2a2e1757b7590d140 plugin-sdk-api-baseline.json
0dc8abcefccfe7d19280bde5fb2c0c69cf73b782d47e3759e2984baf904fe07c plugin-sdk-api-baseline.jsonl

View File

@@ -1102,6 +1102,585 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
const registry = createEmptyPluginRegistry();
registry.agentToolResultMiddlewares.push({
pluginId: "receipt-redactor",
pluginName: "Receipt redactor",
rawHandler: () => undefined,
handler: (event: { result: AgentToolResult<unknown> }) => ({
result: {
content: event.result.content,
details: { redacted: true },
},
}),
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
receipt: {
primaryPlatformMessageId: "imessage-6264",
platformMessageIds: ["imessage-6264"],
},
}),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "chat-1",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "chat-1",
text: "visible reply",
}),
]);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "853",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps normalized explicit source routes terminal", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "sms",
plugin: {
id: "sms",
messaging: {
normalizeTarget: (raw: string) => {
const digits = raw.replace(/\D/gu, "");
return digits.length === 11 && digits.startsWith("1") ? `+${digits}` : raw.trim();
},
},
},
source: "test",
},
]),
);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { ok: true, messageId: "sms-853" }),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "sms",
currentChannelId: "sms:+12069106512",
currentMessagingTarget: "+12069106512",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "sms",
target: "+1 (206) 910-6512",
messageId: "853",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "sms",
to: "+12069106512",
text: "visible reply",
}),
]);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-857",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-857",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "857",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "+12069106512",
text: "visible reply",
}),
]);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
const receiptText = JSON.stringify({
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-861",
});
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-861",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "861",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText(receiptText));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not let dry-run reply receipts terminate message-tool-only source replies", async () => {
const receiptText = JSON.stringify({
deliveryStatus: "dry_run",
dryRun: true,
replyToId: "provider-guid-862",
});
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-862",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "862",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText(receiptText));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not record dry-run reply actions as committed sends", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Dry run.", {
deliveryStatus: "dry_run",
dryRun: true,
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
currentMessageId: "provider-guid-862",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "862",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Dry run."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "863",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "865",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("records message-tool-owned terminal replies as delivered source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { ok: true }),
terminate: true,
} as AgentToolResult<unknown>,
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "867",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
});
const firstResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
const secondResult = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-2",
namespace: null,
tool: "message",
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBe(true);
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
});
it("does not mark explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "other-chat-message" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "channel:other",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "+12069106512",
messageId: "853",
message: "cross-provider reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "thread reply from top-level source",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let matching reply receipts override explicit non-source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "other-chat-message",
repliedTo: "provider-guid-853",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
currentMessageId: "provider-guid-853",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "other-chat",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let provider target aliases override source routes", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
plugin: {
id: "slack",
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
actions: {
messageActionTargetAliases: {
reply: {
aliases: ["chatGuid"],
deliveryTargetAliases: ["chatGuid"],
},
},
},
},
source: "test",
},
]),
);
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "channel:c1",
currentMessagingTarget: "channel:c1",
currentMessageId: "provider-guid-854",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
chatGuid: "Channel:C2",
messageId: "854",
message: "cross-chat reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "slack",
to: "channel:c2",
text: "cross-chat reply",
}),
]);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",

View File

@@ -18,6 +18,8 @@ import {
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
isReplaySafeToolCall,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
@@ -63,9 +65,11 @@ type CodexDynamicToolHookContext = {
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentMessageId?: string | number;
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
@@ -100,6 +104,225 @@ function applyCurrentMessageProvider(
return { ...args, provider };
}
function normalizeRouteToken(value: string | number | undefined): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
const normalized = value?.trim().toLowerCase();
return normalized ? normalized : undefined;
}
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
const tokens = new Set<string>();
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
if (currentTarget) {
tokens.add(currentTarget);
}
if (currentChannel) {
tokens.add(currentChannel);
}
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
if (channelPrefixIndex >= 0 && currentChannel) {
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
for (const segment of unprefixedChannel.split(/[;,]/u)) {
const token = normalizeRouteToken(segment);
if (token) {
tokens.add(token);
}
}
}
}
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
}
}
return tokens;
}
function routeTokenMatchesSource(
token: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
}
function routeProviderMatchesSource(
provider: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(provider);
if (!normalized) {
return false;
}
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
}
function routeTokenMatchesCurrentMessage(
token: string | number | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return (
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
);
}
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
const value = record[key];
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function explicitRouteTokensMismatchCurrent(
args: Record<string, unknown>,
keys: readonly string[],
currentToken: string | number | undefined,
): boolean {
const normalizedCurrent = normalizeRouteToken(currentToken);
if (!normalizedCurrent) {
return false;
}
return keys.some((key) => {
const normalized = normalizeRouteToken(readRouteToken(args, key));
return normalized !== undefined && normalized !== normalizedCurrent;
});
}
function explicitThreadRouteTargetsNonSource(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
const explicitThreadTokens = [
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
normalizeRouteToken(messagingTarget?.threadId),
].filter((value): value is string => value !== undefined);
if (explicitThreadTokens.length === 0) {
return false;
}
return (
normalizedCurrentThread === undefined ||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
);
}
function replyReceiptMatchesCurrentMessage(
value: unknown,
hookContext: CodexDynamicToolHookContext | undefined,
depth = 0,
): boolean {
if (depth > 4 || value === null) {
return false;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
return false;
}
try {
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
} catch {
return false;
}
}
if (typeof value !== "object") {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
}
const record = value as Record<string, unknown>;
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
if (
routeTokenMatchesCurrentMessage(
typeof record[key] === "string" ? record[key] : undefined,
hookContext,
)
) {
return true;
}
}
for (const key of [
"content",
"details",
"payload",
"receipt",
"result",
"results",
"sendResult",
"text",
]) {
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
return true;
}
}
return false;
}
function hasExplicitNonSourceMessageRoute(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
if (
provider &&
currentProvider !== provider &&
!routeProviderMatchesSource(provider, hookContext)
) {
return true;
}
}
const targetValues = [
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
typeof args[key] === "string" ? args[key] : undefined,
),
...(Array.isArray(args.targets)
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
: []),
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
return true;
}
if (
explicitRouteTokensMismatchCurrent(
args,
EXPLICIT_MESSAGE_REPLY_KEYS,
hookContext?.currentMessageId,
)
) {
return true;
}
if (
messagingTarget?.to !== undefined &&
!routeTokenMatchesSource(messagingTarget.to, hookContext)
) {
return true;
}
if (messagingTarget?.to !== undefined) {
return false;
}
if (targetValues.length === 0) {
return false;
}
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
return true;
}
return false;
}
/** Runtime bridge returned to Codex app-server attempt code. */
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
@@ -114,6 +337,7 @@ export type CodexDynamicToolBridge = {
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -132,6 +356,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
// spawn_agent remains the primary Codex subagent surface.
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
/**
@@ -176,6 +404,7 @@ export function createCodexDynamicToolBridge(params: {
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
didDeliverSourceReplyViaMessageTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
@@ -333,10 +562,9 @@ export function createCodexDynamicToolBridge(params: {
executedArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const messagingTarget = isMessagingTool(toolName)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
@@ -358,12 +586,53 @@ export function createCodexDynamicToolBridge(params: {
},
terminalType,
);
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
executedArgs,
params.hookContext,
confirmedMessagingTarget,
);
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
toolName,
args: executedArgs,
result,
hookResult: rawResult,
isError: resultIsError,
allowExplicitSourceRoute: !blocksSourceReplyTermination,
});
const receiptConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
normalizeRouteToken(
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
) === "reply" &&
!resultIsError &&
!blocksSourceReplyTermination &&
isDeliveredMessagingToolResult({
toolName,
args: executedArgs,
result,
hookResult: rawResult,
isError: resultIsError,
}) &&
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
const toolConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
withDynamicToolTermination(
response,
rawResult.terminate === true ||
result.terminate === true ||
isToolResultYield(rawResult) ||
isToolResultYield(result),
isToolResultYield(result) ||
deliveredSourceReply ||
receiptConfirmedSourceReply,
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -801,9 +1070,22 @@ function collectToolTelemetry(params: {
}
}
}
if (!isMessagingTool(params.toolName)) {
return;
}
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
if (!isMessagingSendAction && !params.messagingTarget) {
return;
}
if (
!isMessagingTool(params.toolName) ||
!isMessagingToolSendAction(params.toolName, params.args)
!isMessagingSendAction &&
!isDeliveredMessagingToolResult({
toolName: params.toolName,
args: params.args,
result: params.result,
hookResult: params.mediaTrustResult,
isError: params.isError,
})
) {
return;
}

View File

@@ -836,6 +836,19 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("propagates message-tool-only source reply delivery telemetry", async () => {
const projector = await createProjector();
const result = projector.buildResult({
...buildEmptyToolTelemetry(),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
});
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
});
it("does not promote repeated tool progress text to the final assistant reply", async () => {
const onToolResult = vi.fn();
const projector = await createProjector({

View File

@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -411,6 +412,8 @@ export class CodexAppServerEventProjector {
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,

View File

@@ -841,9 +841,11 @@ export async function runCodexAppServerAttempt(
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentMessageId: params.currentMessageId,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},

View File

@@ -24,4 +24,4 @@ export {
listMemoryFiles,
normalizeExtraMemoryPaths,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
export { getMemorySearchManager } from "openclaw/plugin-sdk/memory-core-engine-runtime";
export { getMemorySearchManager } from "./memory/index.js";

View File

@@ -199,17 +199,11 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => {
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
type MemorySearchRuntimeDebug,
requireNodeSqlite,
resolveMemoryBackendConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js";
import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js";
@@ -263,14 +257,6 @@ describe("QmdMemoryManager", () => {
return mock.mock.calls.map((call: unknown[]) => String(call[0]));
}
function qmdCommandCalls(): string[][] {
return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
}
function countQmdCommand(predicate: (args: string[]) => boolean): number {
return qmdCommandCalls().filter(predicate).length;
}
function expectMockMessageContains(mock: Mock, text: string): void {
expect(mockMessages(mock).join("\n")).toContain(text);
}
@@ -291,246 +277,6 @@ describe("QmdMemoryManager", () => {
);
});
it("reuses persisted collection validation across transient cli managers", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0);
});
it("does not cache incomplete collection validation", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "permission denied", 1);
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.close();
spawnMock.mockClear();
spawnMock.mockImplementation(() => createMockChild());
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("runs collection validation when the runtime cache store is unavailable", async () => {
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
const manager = await createManager({ mode: "cli" });
await manager.manager.close();
} finally {
await configureMemoryCoreDreamingStateForTests();
}
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("reports collection validation debug only once per validation run", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined();
});
it("misses collection validation cache when managed collection config changes", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
const changedCfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
...(cfg.memory?.qmd ?? {}),
paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockClear();
const second = await createManager({ mode: "cli", cfg: changedCfg });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
});
it("bypasses validation cache for missing-collection search repair", async () => {
await configureMemoryCoreDreamingStateForTests();
const { manager } = await createManager();
spawnMock.mockClear();
let searchAttempts = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
searchAttempts += 1;
if (searchAttempts === 1) {
emitAndClose(child, "stderr", "collection workspace-main not found", 1);
} else {
emitAndClose(child, "stdout", "[]");
}
return child;
}
return createMockChild();
});
const debug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
expect(searchAttempts).toBe(2);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force");
});
it("reuses persisted qmd multi-collection support probe across managers", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
});
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const debug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit");
expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2);
});
it("reports multi-collection probe debug only when the probe runs", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined();
});
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.lstat(targetPath);
@@ -660,7 +406,6 @@ describe("QmdMemoryManager", () => {
delete (globalThis as Record<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[QMD_EMBED_QUEUE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[MEMORY_EMBEDDING_PROVIDERS_KEY];
resetMemoryCoreDreamingStateForTests();
});
it("debounces back-to-back sync calls", async () => {

View File

@@ -74,15 +74,6 @@ import {
type QmdSessionArtifactMapping,
} from "../qmd-session-artifacts.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
import {
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
} from "./qmd-runtime-cache.js";
import {
countChokidarWatchedEntries,
type MemoryWatchPressureWarningState,
@@ -333,14 +324,6 @@ type ManagedCollection = {
kind: "memory" | "custom" | "sessions";
};
type QmdCollectionValidationDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
>;
type QmdMultiCollectionProbeDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
>;
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
type QmdManagerMode = "full" | "status" | "cli";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
@@ -470,9 +453,6 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
private multiCollectionFilterSupported: boolean | null = null;
private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined;
private currentSearchMultiCollectionProbeDebug: QmdMultiCollectionProbeDebug | undefined;
private currentSearchPlanDebug: QmdSearchPlanDebug | undefined;
private constructor(params: {
agentId: string;
@@ -632,118 +612,11 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private qmdRuntimeCacheSources(): string[] {
return [...this.sources].toSorted();
}
private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] {
return this.qmd.collections.map((collection) => ({
name: collection.name,
kind: collection.kind,
path: collection.path,
pattern: collection.pattern,
}));
}
private buildQmdCollectionValidationCacheContext(): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
collections: this.qmdRuntimeCacheCollections(),
sources: this.qmdRuntimeCacheSources(),
};
}
private buildQmdMultiCollectionProbeCacheContext(): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
sources: this.qmdRuntimeCacheSources(),
};
}
private recordSearchPlanDebug(params: {
command: "query" | "search" | "vsearch";
collectionNames: string[];
collectionGroups: string[][];
}): void {
const sources = uniqueValues(
params.collectionNames
.map((collectionName) => this.collectionRoots.get(collectionName)?.kind)
.filter((source): source is MemorySource => Boolean(source)),
);
this.currentSearchPlanDebug = {
command: params.command,
collectionCount: params.collectionNames.length,
groupCount: params.collectionGroups.length,
sources,
};
}
private resetQmdSearchRuntimeDebug(): void {
this.currentSearchMultiCollectionProbeDebug = undefined;
this.currentSearchPlanDebug = undefined;
}
private consumeQmdRuntimeDebug(): MemorySearchRuntimeDebug["qmd"] | undefined {
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
if (this.pendingCollectionValidationDebug) {
debug.collectionValidation = this.pendingCollectionValidationDebug;
}
if (this.currentSearchMultiCollectionProbeDebug) {
debug.multiCollectionProbe = this.currentSearchMultiCollectionProbeDebug;
}
if (this.currentSearchPlanDebug) {
debug.searchPlan = this.currentSearchPlanDebug;
}
this.pendingCollectionValidationDebug = undefined;
this.currentSearchMultiCollectionProbeDebug = undefined;
this.currentSearchPlanDebug = undefined;
return Object.keys(debug).length > 0 ? debug : undefined;
}
private async ensureCollectionPathsBestEffort(): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.ensureCollectionPath(collection);
} catch (err) {
log.warn(
`qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`,
);
}
}
}
private async ensureCollections(options?: { force?: boolean }): Promise<void> {
const startedAt = Date.now();
const cacheContext = this.buildQmdCollectionValidationCacheContext();
if (!options?.force) {
const cached = await readQmdCollectionValidationCache(cacheContext);
if (cached.state === "hit") {
await this.ensureCollectionPathsBestEffort();
this.pendingCollectionValidationDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: cached.value.validation.collectionCount,
listCalls: 0,
showCalls: 0,
};
return;
}
}
const stats = { listCalls: 0, showCalls: 0 };
let validationComplete = true;
private async ensureCollections(): Promise<void> {
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = await this.listCollectionsBestEffort(stats);
const existing = await this.listCollectionsBestEffort();
await this.migrateLegacyUnscopedCollections(existing);
@@ -758,7 +631,6 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
validationComplete = false;
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
}
}
@@ -789,31 +661,13 @@ export class QmdMemoryManager implements MemorySearchManager {
pattern: collection.pattern,
});
} else {
validationComplete = false;
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
}
validationComplete = false;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
const wroteCache = validationComplete
? await writeQmdCollectionValidationCache(cacheContext)
: false;
this.pendingCollectionValidationDebug = {
cacheState: validationComplete
? options?.force
? "bypass-force"
: wroteCache
? "write"
: "error"
: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: this.qmd.collections.length,
listCalls: stats.listCalls,
showCalls: stats.showCalls,
};
}
private async tryRebindSameNameCollection(params: {
@@ -859,15 +713,9 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async listCollectionsBestEffort(stats?: {
listCalls: number;
showCalls: number;
}): Promise<Map<string, ListedCollection>> {
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {
if (stats) {
stats.listCalls += 1;
}
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -889,9 +737,6 @@ export class QmdMemoryManager implements MemorySearchManager {
continue;
}
try {
if (stats) {
stats.showCalls += 1;
}
const showResult = await this.runQmd(["collection", "show", collection.name], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -1118,7 +963,7 @@ export class QmdMemoryManager implements MemorySearchManager {
log.warn(
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
);
await this.ensureCollections({ force: true });
await this.ensureCollections();
return true;
}
@@ -1473,7 +1318,6 @@ export class QmdMemoryManager implements MemorySearchManager {
if (searchSignal?.aborted) {
throw asAbortError(searchSignal);
}
this.resetQmdSearchRuntimeDebug();
const trimmed = query.trim();
if (!trimmed) {
return [];
@@ -1559,11 +1403,6 @@ export class QmdMemoryManager implements MemorySearchManager {
collectionNames,
searchSignal,
);
this.recordSearchPlanDebug({
command: qmdSearchCommand,
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1595,11 +1434,6 @@ export class QmdMemoryManager implements MemorySearchManager {
collectionNames,
searchSignal,
);
this.recordSearchPlanDebug({
command: "query",
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1678,7 +1512,6 @@ export class QmdMemoryManager implements MemorySearchManager {
configuredMode: qmdSearchCommand,
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
qmd: this.consumeQmdRuntimeDebug(),
});
let ranked = results;
if (opts?.sources?.length) {
@@ -3554,18 +3387,6 @@ export class QmdMemoryManager implements MemorySearchManager {
if (this.multiCollectionFilterSupported !== null) {
return this.multiCollectionFilterSupported;
}
const startedAt = Date.now();
const cacheContext = this.buildQmdMultiCollectionProbeCacheContext();
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
if (cached.state === "hit") {
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
this.currentSearchMultiCollectionProbeDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
return this.multiCollectionFilterSupported;
}
try {
const result = await this.runQmd(["--help"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
@@ -3574,26 +3395,12 @@ export class QmdMemoryManager implements MemorySearchManager {
const helpText = `${result.stdout}\n${result.stderr}`;
this.multiCollectionFilterSupported =
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
const wroteCache = await writeQmdMultiCollectionProbeCache(
cacheContext,
this.multiCollectionFilterSupported,
);
this.currentSearchMultiCollectionProbeDebug = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
} catch (err) {
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
if (signal?.aborted) {
throw asAbortError(signal);
}
this.multiCollectionFilterSupported = false;
this.currentSearchMultiCollectionProbeDebug = {
cacheState: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
}
return this.multiCollectionFilterSupported;

View File

@@ -1,289 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
openMemoryCoreStateStore,
memoryCoreWorkspaceEntryKey,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import {
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
buildQmdMultiCollectionProbeCacheContextHash,
clearQmdCollectionValidationCache,
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
} from "./qmd-runtime-cache.js";
const tempRoots: string[] = [];
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
});
afterAll(async () => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) {
await fs.rm(root, { recursive: true, force: true });
}
}
resetMemoryCoreDreamingStateForTests();
});
async function clearStore(namespace: string): Promise<void> {
try {
await openMemoryCoreStateStore({
namespace,
maxEntries: 1_000,
}).clear();
} catch {
// fail open
}
}
afterEach(async () => {
await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE);
await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE);
});
function makeWorkspace(): Promise<string> {
const prefix = path.join(os.tmpdir(), `qmd-runtime-cache-${Date.now()}-`);
return fs.mkdtemp(prefix).then((workspaceDir) => {
tempRoots.push(workspaceDir);
return workspaceDir;
});
}
function managedCollections(): QmdRuntimeManagedCollection[] {
return [
{
name: "project-notes",
kind: "memory",
path: "/repo/project-notes",
pattern: "*.md",
},
{
name: "sessions",
kind: "sessions",
path: "/repo/sessions",
pattern: "*",
},
];
}
function collectionValidationContext(
workspaceDir: string,
): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
collections: managedCollections(),
sources: ["memory", "sessions"],
};
}
function multiCollectionProbeContext(
workspaceDir: string,
): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
sources: ["memory", "sessions"],
};
}
describe("qmd-runtime-cache", () => {
it("writes and reads collection validation cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
const writeStartedAtMs = 1_000;
const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdCollectionValidationCache(
{ ...context, sources: ["sessions", "memory"] },
writeStartedAtMs + 1,
);
expect(read).toMatchObject({
state: "hit",
value: {
validation: {
ok: true,
collectionCount: context.collections.length,
},
},
});
});
it("writes and reads multi-collection probe cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const writeStartedAtMs = 2_000;
const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1);
expect(read).toMatchObject({
state: "hit",
value: {
multiCollectionProbe: {
supported: true,
},
},
});
});
it("scopes cache entries by workspace", async () => {
const firstWorkspace = await makeWorkspace();
const secondWorkspace = await makeWorkspace();
const context = collectionValidationContext(firstWorkspace);
expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true);
const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = {
...context,
workspaceDir: secondWorkspace,
qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"),
};
const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001);
expect(miss).toStrictEqual({ state: "miss" });
});
it("misses collection validation cache when managed collection paths change", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true);
const changedContext: QmdRuntimeCollectionValidationCacheContext = {
...context,
collections: context.collections.map((collection) =>
collection.name === "project-notes"
? { ...collection, path: `${collection.path}-moved` }
: collection,
),
};
expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({
state: "miss",
});
});
it("treats cache misses for malformed values and expired entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const nowMs = 4_000;
await writeQmdMultiCollectionProbeCache(context, false, nowMs);
const key = memoryCoreWorkspaceEntryKey(
workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`,
);
const store = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
await store.register(key, {
version: 1,
createdAtMs: "bad",
expiresAtMs: 0,
keyHash: "bad",
multiCollectionProbe: { supported: true },
});
const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1);
expect(malformed).toStrictEqual({ state: "miss" });
const expired = await readQmdMultiCollectionProbeCache(
context,
nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1,
);
expect(expired).toStrictEqual({ state: "miss" });
});
it("uses separate namespaces for validation and probe entries", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true);
const validationStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: 1_000,
});
const probeStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
expect((await validationStore.entries()).length).toBeGreaterThan(0);
expect((await probeStore.entries()).length).toBeGreaterThan(0);
});
it("fails open when state store is unavailable", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false);
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false);
} finally {
await configureMemoryCoreDreamingStateForTests();
}
});
it("exposes bounded TTL windows", () => {
expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000);
expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000);
});
it("can clear cache keys explicitly", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true);
await clearQmdCollectionValidationCache(validationContext);
await clearQmdMultiCollectionProbeCache(probeContext);
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
});
});

View File

@@ -1,432 +0,0 @@
// Memory Core QMD runtime cache helpers.
import { createHash } from "node:crypto";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE =
"qmd-runtime-cache.collection-validation";
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
"qmd-runtime-cache.multi-collection-probe";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1;
export type QmdRuntimeManagedCollection = {
name: string;
kind: "memory" | "custom" | "sessions";
path: string;
pattern: string;
};
type QmdRuntimeCacheContextBase = {
workspaceDir: string;
agentId: string;
qmdCommand: string;
qmdVersion?: string;
qmdIndexPath: string;
searchMode: string;
};
export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & {
collections: readonly QmdRuntimeManagedCollection[];
sources: readonly string[];
};
export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & {
sources: readonly string[];
};
export type QmdRuntimeCacheCollectionValidationEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
validation: {
ok: true;
collectionConfigHash: string;
collectionCount: number;
};
};
export type QmdRuntimeCacheMultiCollectionProbeEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
multiCollectionProbe: {
supported: boolean;
};
};
export type QmdRuntimeCacheResult<T> =
| {
state: "hit";
value: T;
}
| { state: "miss" };
function normalizeText(value: string): string {
return value.trim();
}
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
return {
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
};
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function normalizePathIdentity(value: string): string {
const normalized =
process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value);
return hashText(normalized);
}
function sortedUnique(values: readonly string[]): string[] {
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted();
}
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
const normalized = collections
.map((collection) => ({
...normalizeCollection(collection),
}))
.toSorted(
(left, right) =>
left.name.localeCompare(right.name) ||
left.kind.localeCompare(right.kind) ||
left.pathHash.localeCompare(right.pathHash) ||
left.pattern.localeCompare(right.pattern),
)
.map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`)
.join(";");
return hashText(normalized);
}
function buildCollectionValidationCacheContextInput(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
collectionConfigHash: buildCollectionConfigHash(params.collections),
});
}
function buildMultiCollectionProbeCacheContextInput(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
});
}
function buildCollectionValidationCacheHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return hashText(buildCollectionValidationCacheContextInput(params));
}
function buildMultiCollectionProbeCacheHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return hashText(buildMultiCollectionProbeCacheContextInput(params));
}
export function buildQmdCollectionValidationCacheContextHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return buildCollectionValidationCacheHash(params);
}
export function buildQmdMultiCollectionProbeCacheContextHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return buildMultiCollectionProbeCacheHash(params);
}
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
});
}
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
});
}
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`,
);
}
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`,
);
}
function normalizeCollectionValidationEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheCollectionValidationEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const validation = record.validation as unknown;
if (typeof validation !== "object" || validation === null) {
return undefined;
}
const validationRecord = validation as Record<string, unknown>;
if (validationRecord.ok !== true) {
return undefined;
}
if (typeof validationRecord.collectionConfigHash !== "string") {
return undefined;
}
if (typeof validationRecord.collectionCount !== "number") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
validation: {
ok: true,
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
},
};
}
function normalizeMultiCollectionProbeEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheMultiCollectionProbeEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const probe = record.multiCollectionProbe as unknown;
if (typeof probe !== "object" || probe === null) {
return undefined;
}
const probeRecord = probe as Record<string, unknown>;
if (typeof probeRecord.supported !== "boolean") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
multiCollectionProbe: {
supported: probeRecord.supported,
},
};
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
try {
const store = collectionValidationStore();
const key = collectionValidationEntryKey(params);
const expectedKeyHash = buildCollectionValidationCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = collectionValidationEntryKey(params);
const keyHash = buildCollectionValidationCacheHash(params);
const collectionConfigHash = buildCollectionConfigHash(params.collections);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
const store = collectionValidationStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
validation: {
ok: true,
collectionConfigHash,
collectionCount: params.collections.length,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
): Promise<void> {
try {
const store = collectionValidationStore();
await store.delete(collectionValidationEntryKey(params));
} catch {
// fail open
}
}
export async function readQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
try {
const store = multiCollectionProbeStore();
const key = multiCollectionProbeEntryKey(params);
const expectedKeyHash = buildMultiCollectionProbeCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
supported: boolean,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = multiCollectionProbeEntryKey(params);
const keyHash = buildMultiCollectionProbeCacheHash(params);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
const store = multiCollectionProbeStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
multiCollectionProbe: {
supported,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): Promise<void> {
try {
const store = multiCollectionProbeStore();
await store.delete(multiCollectionProbeEntryKey(params));
} catch {
// fail open
}
}

View File

@@ -326,10 +326,6 @@ describe("getMemorySearchManager caching", () => {
expect(first.manager).toBe(second.manager);
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
expect(first.debug?.managerCacheState).toBe("cached-full-miss");
expect(second.debug?.managerCacheState).toBe("cached-full-hit");
expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash);
});
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
@@ -810,10 +806,6 @@ describe("getMemorySearchManager caching", () => {
const fullManager = requireManager(full);
const cliManager = requireManager(cli);
expect(cli.debug?.managerCacheState).toBe("transient-cli");
expect(full.debug?.managerCacheState).toBe("cached-full-miss");
expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash);
expect(cliManager).toBe(cliPrimary);
expect(cliManager).not.toBe(fullManager);
const fullCreateParams = qmdCreateParams();

View File

@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
// Memory Core plugin module implements search manager behavior.
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -49,24 +48,6 @@ type QmdManagerOpenFailure = {
retryAfterMs: number;
};
type MemorySearchManagerCacheState =
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
export type MemorySearchManagerDebug = {
backend?: "builtin" | "qmd";
purpose?: MemorySearchManagerPurpose;
managerMs?: number;
managerCacheState?: MemorySearchManagerCacheState;
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
type MemorySearchManagerCacheStore = {
qmdManagerCache: Map<string, CachedQmdManagerEntry>;
pendingQmdManagerCreates: Map<string, PendingQmdManagerCreate>;
@@ -128,7 +109,6 @@ function loadQmdManagerModule() {
export type MemorySearchManagerResult = {
manager: Maybe<MemorySearchManager>;
error?: string;
debug?: MemorySearchManagerDebug;
};
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
@@ -169,42 +149,11 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void
}
}
function hashQmdManagerIdentity(identityKey: string): string {
return createHash("sha256").update(identityKey).digest("hex");
}
function applyManagerDebug(
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult {
if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) {
return result;
}
return {
...result,
debug: {
...(result.debug ?? {}),
...debug,
},
};
}
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
const acquireStartedAt = Date.now();
const purpose = params.purpose ?? "default";
const finish = (
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult =>
applyManagerDebug(result, {
purpose,
managerMs: Math.max(0, Date.now() - acquireStartedAt),
...debug,
});
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const qmdResolved = resolved.qmd;
@@ -214,7 +163,6 @@ export async function getMemorySearchManager(params: {
const transient = params.purpose === "status" || params.purpose === "cli";
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
const debugIdentityHash = hashQmdManagerIdentity(identityKey);
const createPrimaryQmdManager = async (
mode: "full" | "status" | "cli",
@@ -306,24 +254,10 @@ export async function getMemorySearchManager(params: {
// Status callers often close the manager they receive. Wrap the live
// full manager with a no-op close so health/status probes do not tear
// down the active QMD manager for the process.
return finish(
{ manager: new BorrowedMemoryManager(cached.manager) },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
return { manager: new BorrowedMemoryManager(cached.manager) };
}
if (params.purpose !== "cli") {
return finish(
{ manager: cached.manager },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
return { manager: cached.manager };
}
}
@@ -332,44 +266,20 @@ export async function getMemorySearchManager(params: {
params.purpose === "cli" ? "cli" : "status",
);
return manager
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
}
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
if (recentFailure) {
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
return finish(
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
{
backend: "qmd",
managerCacheState: "recent-failure-cooldown",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
},
);
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
}
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
if (pending) {
await pending.promise;
return finish(await getMemorySearchManager(params), {
backend: "qmd",
managerCacheState: "pending-create-wait",
qmdIdentityHash: debugIdentityHash,
});
return await getMemorySearchManager(params);
}
let pendingFailureReason: string | undefined;
@@ -399,25 +309,11 @@ export async function getMemorySearchManager(params: {
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
const manager = await pendingCreate.promise;
return manager
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: "cached-full-miss",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
}
return finish(await getBuiltinMemorySearchManager(params), {
backend: "builtin",
});
return await getBuiltinMemorySearchManager(params);
}
async function getBuiltinMemorySearchManagerAfterQmdFailure(

View File

@@ -67,28 +67,18 @@ export async function getMemoryManagerContextWithPurpose(params: {
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const startedAt = Date.now();
const { manager, debug, error } = await getMemorySearchManager({
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager
? {
manager,
debug: {
...debug,
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
},
}
: { error };
return manager ? { manager } : { error };
}
export function createMemoryTool(params: {

View File

@@ -422,14 +422,6 @@ describe("memory_search unavailable payloads", () => {
configuredMode: opts.qmdSearchModeOverride ?? "query",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
@@ -478,18 +470,6 @@ describe("memory_search unavailable payloads", () => {
fallback?: unknown;
hits?: unknown;
searchMs?: number;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
managerCacheState?: unknown;
qmd?: {
searchPlan?: {
command?: unknown;
collectionCount?: unknown;
groupCount?: unknown;
sources?: unknown;
};
};
};
};
expect(details.mode).toBe("query");
@@ -499,94 +479,6 @@ describe("memory_search unavailable payloads", () => {
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.hits).toBe(1);
expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBeUndefined();
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("includes manager acquisition timing and cache-state debug payload", async () => {
setMemorySearchManagerImpl(
async () =>
({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}) as any,
);
setMemorySearchImpl(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
]);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd" },
},
});
const result = await tool.execute("manager-debug", { query: "favorite food" });
const details = result.details as {
debug?: {
backend?: string;
managerMs?: number;
toolMs?: number;
outsideSearchMs?: number;
managerCacheState?: string;
hits?: number;
searchMs?: number;
};
};
expect(details.debug?.backend).toBe("qmd");
expect(details.debug?.managerMs).toBe(17);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBe("cached-full-hit");
});
});

View File

@@ -415,7 +415,6 @@ export function createMemorySearchTool(options: {
const outcome = await runMemorySearchToolWithDeadline({
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
run: async (deadlineSignal) => {
const toolStartedAt = Date.now();
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
@@ -472,20 +471,13 @@ export function createMemorySearchTool(options: {
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let managerMs: number | undefined;
let managerCacheState: string | undefined;
let searchDebug:
| {
backend: string;
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
searchMs: number;
managerCacheState?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
hits: number;
}
| undefined;
@@ -514,8 +506,6 @@ export function createMemorySearchTool(options: {
},
...(searchSources ? { sources: searchSources } : {}),
};
managerMs = memory.debug?.managerMs;
managerCacheState = memory.debug?.managerCacheState;
try {
rawResults = await activeMemory.manager.search(query, searchOptions);
} catch (error) {
@@ -532,8 +522,6 @@ export function createMemorySearchTool(options: {
if ("error" in refreshed) {
throw error;
}
managerMs = refreshed.debug?.managerMs;
managerCacheState = refreshed.debug?.managerCacheState;
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
@@ -593,7 +581,6 @@ export function createMemorySearchTool(options: {
fallback = status.fallback;
const latestDebug = runtimeDebug.at(-1);
searchMode = latestDebug?.effectiveMode;
const searchMs = Math.max(0, Date.now() - searchStartedAt);
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
@@ -602,10 +589,7 @@ export function createMemorySearchTool(options: {
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
managerMs,
searchMs,
managerCacheState,
qmd: latestDebug?.qmd,
searchMs: Math.max(0, Date.now() - searchStartedAt),
hits: rawResults.length,
};
});
@@ -636,14 +620,6 @@ export function createMemorySearchTool(options: {
maxResults: effectiveMax,
balanceCorpora: requestedCorpus === "all",
});
if (searchDebug) {
const finalToolMs = Math.max(0, Date.now() - toolStartedAt);
searchDebug = {
...searchDebug,
toolMs: finalToolMs,
outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs),
};
}
return jsonResult({
results,
provider,

View File

@@ -168,42 +168,23 @@ describe("runtime parity", () => {
const scoped = __testing.filterMockRequestsForParentPrompt(
[
{
prompt: "Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
allInputText:
"Delegate one bounded QA task to a subagent. Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
plannedToolName: "read",
},
{
prompt: "Delegate one bounded QA task to a subagent.",
allInputText: "Delegate one bounded QA task to a subagent.",
plannedToolName: "sessions_spawn",
},
{
prompt: "Continue the bounded QA task with the retained child result.",
allInputText:
"Delegate one bounded QA task to a subagent. Continue the bounded QA task with the retained child result.",
plannedToolName: "sessions_spawn",
},
{
allInputText: "Inspect the QA workspace and return one concise protocol note.",
plannedToolName: "read",
},
{
prompt: "Delegate one bounded QA task to a subagent.",
allInputText: "Delegate one bounded QA task to a subagent. Tool result: child accepted.",
toolOutput: "child accepted",
},
],
"Delegate one bounded QA task to a subagent.",
[
"Delegate one bounded QA task to a subagent.",
"Continue the bounded QA task with the retained child result.",
],
);
expect(scoped).toHaveLength(3);
expect(scoped).toHaveLength(2);
expect(scoped.map((request) => request.plannedToolName ?? "result")).toEqual([
"sessions_spawn",
"sessions_spawn",
"result",
]);

View File

@@ -120,7 +120,6 @@ type RuntimeParityTranscriptRecord = {
};
type RuntimeParityMockRequestSnapshot = {
prompt?: string;
allInputText?: string;
plannedToolName?: string;
plannedToolArgs?: unknown;
@@ -760,22 +759,14 @@ function resolveRuntimeParityToolCalls(params: {
function filterMockRequestsForParentPrompt(
requests: RuntimeParityMockRequestSnapshot[],
parentPrompt: string,
parentPrompts: readonly string[] = [parentPrompt],
) {
const normalizedParentPrompts = parentPrompts
.map(normalizeTextForParity)
.filter((prompt) => prompt.length > 0);
if (normalizedParentPrompts.length === 0) {
const normalizedParentPrompt = normalizeTextForParity(parentPrompt);
if (!normalizedParentPrompt) {
return requests;
}
const matching = requests.filter((request) => {
const normalizedPrompt = normalizeTextForParity(request.prompt ?? "");
if (normalizedPrompt) {
return normalizedParentPrompts.some((prompt) => normalizedPrompt.includes(prompt));
}
const normalizedHistory = normalizeTextForParity(request.allInputText ?? "");
return normalizedParentPrompts.some((prompt) => normalizedHistory.includes(prompt));
});
const matching = requests.filter((request) =>
normalizeTextForParity(request.allInputText ?? "").includes(normalizedParentPrompt),
);
return matching.length > 0 ? matching : requests;
}
@@ -975,7 +966,6 @@ async function loadRuntimeParityTranscripts(params: {
async function loadRuntimeParityMockToolCalls(
mockBaseUrl: string | undefined,
parentPrompt: string,
parentPrompts: readonly string[] = [parentPrompt],
): Promise<RuntimeParityToolCall[] | null> {
const normalizedBaseUrl = mockBaseUrl?.trim().replace(/\/+$/u, "");
if (!normalizedBaseUrl) {
@@ -1001,7 +991,6 @@ async function loadRuntimeParityMockToolCalls(
}
const requests = payload.filter(isMessageRecord).map(
(entry): RuntimeParityMockRequestSnapshot => ({
prompt: readNonEmptyString(entry.prompt),
allInputText: readNonEmptyString(entry.allInputText),
plannedToolName: readNonEmptyString(entry.plannedToolName),
plannedToolArgs: entry.plannedToolArgs ?? null,
@@ -1009,7 +998,7 @@ async function loadRuntimeParityMockToolCalls(
}),
);
return resolveToolCallOrderFromMockRequests(
filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts),
filterMockRequestsForParentPrompt(requests, parentPrompt),
);
} catch {
return null;
@@ -1026,16 +1015,12 @@ export async function captureRuntimeParityCell(
});
const transcriptRecords = buildTranscriptRecords(transcriptBytes);
const transcriptToolCalls = resolveToolCallOrder(transcriptRecords);
const parentPrompts = transcriptRecords
.filter((record) => record.role === "user")
.map((record) => extractAssistantText(record.message))
.filter((prompt) => prompt.length > 0);
const parentPrompt = parentPrompts[0] ?? "";
const mockToolCalls = await loadRuntimeParityMockToolCalls(
params.mockBaseUrl,
parentPrompt,
parentPrompts,
);
const parentPrompt =
transcriptRecords
.filter((record) => record.role === "user" && !isToolResultLikeMessage(record.message))
.map((record) => extractAssistantText(record.message))
.find(Boolean) ?? "";
const mockToolCalls = await loadRuntimeParityMockToolCalls(params.mockBaseUrl, parentPrompt);
const gatewayLogs = params.gateway.logs?.();
const sentinelFindings = [
...scanGatewayLogSentinels(gatewayLogs),

View File

@@ -55,39 +55,11 @@ export type MemorySyncParams = {
};
/** Runtime backend/mode diagnostics for memory search. */
export type MemorySearchRuntimeQmdCollectionValidationDebug = {
cacheState?: "hit" | "miss" | "write" | "bypass-force" | "error";
elapsedMs: number;
collectionCount: number;
listCalls?: number;
showCalls?: number;
};
export type MemorySearchRuntimeQmdMultiCollectionProbeDebug = {
cacheState?: "hit" | "miss" | "write" | "error";
elapsedMs: number;
supported: boolean;
};
export type MemorySearchRuntimeQmdSearchPlanDebug = {
command?: "query" | "search" | "vsearch";
collectionCount?: number;
groupCount?: number;
sources?: MemorySource[];
};
export type MemorySearchRuntimeQmdDebug = {
collectionValidation?: MemorySearchRuntimeQmdCollectionValidationDebug;
multiCollectionProbe?: MemorySearchRuntimeQmdMultiCollectionProbeDebug;
searchPlan?: MemorySearchRuntimeQmdSearchPlanDebug;
};
export type MemorySearchRuntimeDebug = {
backend: "builtin" | "qmd";
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
qmd?: MemorySearchRuntimeQmdDebug;
};
/** Result of reading a memory file, optionally paginated/truncated. */

View File

@@ -108,7 +108,7 @@ flow:
- lambda:
params: [text]
expr: "config.expectedReplyGroups.every((group) => group.some((needle) => normalizeLowercaseStringOrEmpty(text).includes(needle)))"
- expr: "30000"
- expr: "env.providerMode === 'mock-openai' ? 10000 : 30000"
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
- if:
expr: "Boolean(env.mock)"
@@ -240,11 +240,7 @@ flow:
message:
expr: "lastError instanceof Error ? formatErrorMessage(lastError) : String(lastError ?? 'fanout retry exhausted')"
- if:
# Codex completes child sessions through its app-server path but
# does not relay the child marker back onto the parent QA channel.
# The shared assertions above already prove both child tool calls
# and child session rows; keep this transport-only proof OpenClaw-specific.
expr: "Boolean(env.mock) && env.gateway.runtimeEnv.OPENCLAW_QA_FORCE_RUNTIME !== 'codex'"
expr: "Boolean(env.mock)"
then:
- forEach:
items:
@@ -257,5 +253,5 @@ flow:
- lambda:
params: [candidate]
expr: "String(candidate.text ?? '').trim() === childCompletionMarker"
- 30000
- 10000
detailsExpr: "details"

View File

@@ -26,9 +26,7 @@ scenario:
config:
sessionKey: agent:qa:long-context-cache-stability
fixtureFile: large-cache-fixture.txt
cacheEvidenceNeedle: CACHE-FIXTURE-0050
cacheEvidenceLine: "CACHE-FIXTURE-0050: stable tool-result evidence for prompt-cache reuse across long sessions."
followupPromptNeedle: Using the already-read
cacheEvidenceNeedle: CACHE-FIXTURE-0550
warmupMarker: QA-LARGE-CACHE-WARMUP-OK
hitMarker: QA-LARGE-CACHE-HIT-OK
@@ -86,17 +84,8 @@ flow:
- set: debugRequests
value:
expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []"
- set: cappedReadOutputIndex
value:
expr: "debugRequests.reduce((found, planned, index) => { if (found >= 0 || !planned.plannedToolCallId || planned.plannedToolName !== 'read' || planned.plannedToolArgs?.path !== config.fixtureFile) return found; const outputOffset = debugRequests.slice(index + 1).findIndex((candidate) => Boolean(candidate.toolOutputCallId) && candidate.toolOutputCallId === planned.plannedToolCallId); if (outputOffset < 0) return found; const output = debugRequests[index + 1 + outputOffset]; const evidence = [planned.allInputText, output.allInputText, output.toolOutput].filter((value) => typeof value === 'string').join('\\n'); const hasCodexFormattedTruncation = evidence.includes('Warning: truncated output') && (evidence.includes('chars truncated') || evidence.includes('tokens truncated')); return evidence.includes(config.cacheEvidenceLine) && (evidence.includes('[Read output capped at 50KB') || evidence.includes('...(OpenClaw truncated dynamic tool result') || evidence.includes('...(truncated)...') || hasCodexFormattedTruncation) ? index + 1 + outputOffset : found; }, -1)"
- set: hasCappedReadEvidence
value:
expr: "cappedReadOutputIndex >= 0"
- set: hasFollowupCacheEvidence
value:
expr: "cappedReadOutputIndex >= 0 && debugRequests.some((request, index) => index > cappedReadOutputIndex && String(request.prompt ?? '').includes(config.followupPromptNeedle) && String(request.allInputText ?? '').includes(config.cacheEvidenceLine))"
- assert:
expr: "!env.mock || (hasCappedReadEvidence && hasFollowupCacheEvidence)"
expr: "!env.mock || debugRequests.some((request, index) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && typeof request.plannedToolCallId === 'string' && debugRequests.slice(index + 1).some((result, resultOffset) => result.toolOutputCallId === request.plannedToolCallId && String(result.toolOutput ?? '').includes(config.cacheEvidenceNeedle) && (String(result.toolOutput ?? '').includes('[Read output capped at 50KB') || (String(result.toolOutput ?? '').includes('...(truncated)...') && String(result.toolOutput ?? '').length <= 13000)) && debugRequests.slice(index + resultOffset + 2).some((followup) => followup.plannedToolName === 'read' && followup.plannedToolArgs?.path === config.fixtureFile && String(followup.allInputText ?? '').includes(config.cacheEvidenceNeedle) && (String(followup.allInputText ?? '').includes('[Read output capped at 50KB') || String(followup.allInputText ?? '').includes('...(truncated)...')))))"
message:
expr: "`large capped read cache evidence was not observed: ${JSON.stringify({ hasCappedReadEvidence, hasFollowupCacheEvidence, requests: debugRequests.slice(-8).map((request) => ({ prompt: request.prompt ?? null, plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, outputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), outputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasEvidenceLine: String(request.allInputText ?? '').includes(config.cacheEvidenceLine) })) })}`"
expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, toolOutputHasNeedle: String(request.toolOutput ?? '').includes(config.cacheEvidenceNeedle), toolOutputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), toolOutputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), inputHasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB'), inputHasCodexTruncation: String(request.allInputText ?? '').includes('...(truncated)...') })))}`"
detailsExpr: "outbound?.text ?? config.hitMarker"

View File

@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10382),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5211),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10386),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -73,9 +73,7 @@ describe("isDeliveredMessagingToolResult", () => {
result: [{ type: "text", text: JSON.stringify({ result: { messageId: "msg-1" } }) }],
}),
).toBe(true);
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(
true,
);
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(true);
expect(isDeliveredMessagingToolResult({ result: { status: "sent" } })).toBe(true);
});
@@ -334,4 +332,47 @@ describe("isDeliveredMessageToolOnlySourceReplyResult", () => {
}),
).toBe(false);
});
it("accepts confirmed explicit routes when the caller verified the source route", () => {
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "reply",
channel: "imessage",
target: "+12069106512",
message: "reply",
},
result: { ok: true, messageId: "imessage-853" },
allowExplicitSourceRoute: true,
}),
).toBe(true);
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "reply",
channel: "imessage",
target: "+12069106512",
message: "reply",
},
result: { ok: true, messageId: "imessage-853" },
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "react",
channel: "imessage",
target: "+12069106512",
},
result: { ok: true },
allowExplicitSourceRoute: true,
}),
).toBe(false);
});
});

View File

@@ -50,6 +50,13 @@ function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
}
function isMessageToolSourceReplyActionName(action: unknown): boolean {
if (isMessageToolSendActionName(action)) {
return true;
}
return typeof action === "string" && action.trim().toLowerCase() === "reply";
}
function normalizeStatus(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
}
@@ -547,6 +554,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
result?: unknown;
hookResult?: unknown;
isError?: boolean;
allowExplicitSourceRoute?: boolean;
}): boolean {
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
return false;
@@ -555,7 +563,12 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
return false;
}
const args = asRecord(params.args);
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
const sourceRouteReplyAction =
params.allowExplicitSourceRoute === true && isMessageToolSourceReplyActionName(args.action);
if (!isMessageToolSendActionName(args.action) && !sourceRouteReplyAction) {
return false;
}
if (hasExplicitMessageRoute(args) && params.allowExplicitSourceRoute !== true) {
return false;
}
return isDeliveredMessagingToolResult(params);

View File

@@ -641,7 +641,7 @@ async function runEmbeddedAgentInternal(
...paramsBase,
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
sessionId: runSessionTarget.sessionId,
sessionKey: normalizeOptionalString(effectiveSessionKey ?? runSessionTarget.sessionKey),
sessionKey: effectiveSessionKey ?? runSessionTarget.sessionKey,
sessionFile: runSessionTarget.sessionFile,
};
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);

View File

@@ -28,6 +28,10 @@ import { truncateUtf16Safe } from "../utils.js";
export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000;
export { FAST_MODE_AUTO_PROGRESS_KIND } from "../auto-reply/reply-payload.js";
export {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
} from "../agents/embedded-agent-message-tool-source-reply.js";
export { formatFastModeAutoProgressText, resolveFastModeForElapsed } from "../shared/fast-mode.js";
export type { AgentMessage } from "../agents/runtime/index.js";
export type { FastModeAutoProgressState } from "../shared/fast-mode.js";

View File

@@ -131,7 +131,7 @@ type FacadeModule = {
getMemorySearchManager: (params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status" | "cli";
purpose?: "default" | "status";
}) => Promise<{
manager: MemorySearchManager | null;
error?: string;

View File

@@ -102,21 +102,6 @@ export type MemoryPluginRuntime = {
purpose?: "default" | "status" | "cli";
}): Promise<{
manager: RegisteredMemorySearchManager | null;
debug?: {
backend?: "builtin" | "qmd";
purpose?: "default" | "status" | "cli";
managerMs?: number;
managerCacheState?:
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
error?: string;
}>;
resolveMemoryBackendConfig(params: {