mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 01:01:58 +08:00
Compare commits
6 Commits
codex/mess
...
feat/qmd-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3da987afe | ||
|
|
643410c1f3 | ||
|
|
8d4e40d293 | ||
|
|
068ae4eb4b | ||
|
|
dad7168c2f | ||
|
|
31a65e0647 |
@@ -1,2 +1,2 @@
|
||||
35b314075ff47453c5d57788861ca0c0e65d6a988b549ab2a2e1757b7590d140 plugin-sdk-api-baseline.json
|
||||
0dc8abcefccfe7d19280bde5fb2c0c69cf73b782d47e3759e2984baf904fe07c plugin-sdk-api-baseline.jsonl
|
||||
ea7c5c6dc96594843238bdc8674e0f03041a61445d6e2d0ab82c30c9ce832f91 plugin-sdk-api-baseline.json
|
||||
65282a8e00237c16745670e2583a289349be1dbd1a0d395789da9dceb1538cf9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1102,585 +1102,6 @@ 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",
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isDeliveredMessagingToolResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -65,11 +63,9 @@ 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"];
|
||||
};
|
||||
@@ -104,225 +100,6 @@ 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[];
|
||||
@@ -337,7 +114,6 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -356,10 +132,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -404,7 +176,6 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -562,9 +333,10 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -586,53 +358,12 @@ 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) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
isToolResultYield(result),
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -1070,22 +801,9 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMessagingTool(params.toolName)) {
|
||||
return;
|
||||
}
|
||||
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
|
||||
if (!isMessagingSendAction && !params.messagingTarget) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isMessagingSendAction &&
|
||||
!isDeliveredMessagingToolResult({
|
||||
toolName: params.toolName,
|
||||
args: params.args,
|
||||
result: params.result,
|
||||
hookResult: params.mediaTrustResult,
|
||||
isError: params.isError,
|
||||
})
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,19 +836,6 @@ 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({
|
||||
|
||||
@@ -53,7 +53,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -412,8 +411,6 @@ 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,
|
||||
|
||||
@@ -841,11 +841,9 @@ 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,
|
||||
},
|
||||
|
||||
@@ -24,4 +24,4 @@ export {
|
||||
listMemoryFiles,
|
||||
normalizeExtraMemoryPaths,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
export { getMemorySearchManager } from "./memory/index.js";
|
||||
export { getMemorySearchManager } from "openclaw/plugin-sdk/memory-core-engine-runtime";
|
||||
|
||||
@@ -199,11 +199,17 @@ 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";
|
||||
|
||||
@@ -257,6 +263,14 @@ 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);
|
||||
}
|
||||
@@ -277,6 +291,246 @@ 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);
|
||||
@@ -406,6 +660,7 @@ 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 () => {
|
||||
|
||||
@@ -74,6 +74,15 @@ 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,
|
||||
@@ -324,6 +333,14 @@ 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;
|
||||
@@ -453,6 +470,9 @@ 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;
|
||||
@@ -612,11 +632,118 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCollections(): Promise<void> {
|
||||
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;
|
||||
// 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();
|
||||
const existing = await this.listCollectionsBestEffort(stats);
|
||||
|
||||
await this.migrateLegacyUnscopedCollections(existing);
|
||||
|
||||
@@ -631,6 +758,7 @@ 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}`);
|
||||
}
|
||||
}
|
||||
@@ -661,13 +789,31 @@ 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: {
|
||||
@@ -713,9 +859,15 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
);
|
||||
}
|
||||
|
||||
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
|
||||
private async listCollectionsBestEffort(stats?: {
|
||||
listCalls: number;
|
||||
showCalls: number;
|
||||
}): 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,
|
||||
});
|
||||
@@ -737,6 +889,9 @@ 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,
|
||||
});
|
||||
@@ -963,7 +1118,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();
|
||||
await this.ensureCollections({ force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1318,6 +1473,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (searchSignal?.aborted) {
|
||||
throw asAbortError(searchSignal);
|
||||
}
|
||||
this.resetQmdSearchRuntimeDebug();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
@@ -1403,6 +1559,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
command: qmdSearchCommand,
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1434,6 +1595,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
command: "query",
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1512,6 +1678,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
configuredMode: qmdSearchCommand,
|
||||
effectiveMode: effectiveSearchMode,
|
||||
fallback: searchFallbackReason,
|
||||
qmd: this.consumeQmdRuntimeDebug(),
|
||||
});
|
||||
let ranked = results;
|
||||
if (opts?.sources?.length) {
|
||||
@@ -3387,6 +3554,18 @@ 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),
|
||||
@@ -3395,12 +3574,26 @@ 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;
|
||||
|
||||
289
extensions/memory-core/src/memory/qmd-runtime-cache.test.ts
Normal file
289
extensions/memory-core/src/memory/qmd-runtime-cache.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
432
extensions/memory-core/src/memory/qmd-runtime-cache.ts
Normal file
432
extensions/memory-core/src/memory/qmd-runtime-cache.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,10 @@ 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 () => {
|
||||
@@ -806,6 +810,10 @@ 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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -48,6 +49,24 @@ 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>;
|
||||
@@ -109,6 +128,7 @@ function loadQmdManagerModule() {
|
||||
export type MemorySearchManagerResult = {
|
||||
manager: Maybe<MemorySearchManager>;
|
||||
error?: string;
|
||||
debug?: MemorySearchManagerDebug;
|
||||
};
|
||||
|
||||
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
|
||||
@@ -149,11 +169,42 @@ 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;
|
||||
@@ -163,6 +214,7 @@ 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",
|
||||
@@ -254,10 +306,24 @@ 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 { manager: new BorrowedMemoryManager(cached.manager) };
|
||||
return finish(
|
||||
{ manager: new BorrowedMemoryManager(cached.manager) },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "cached-full-hit",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (params.purpose !== "cli") {
|
||||
return { manager: cached.manager };
|
||||
return finish(
|
||||
{ manager: cached.manager },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "cached-full-hit",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,20 +332,44 @@ export async function getMemorySearchManager(params: {
|
||||
params.purpose === "cli" ? "cli" : "status",
|
||||
);
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
|
||||
? 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",
|
||||
});
|
||||
}
|
||||
|
||||
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
|
||||
if (recentFailure) {
|
||||
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
|
||||
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
|
||||
return finish(
|
||||
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "recent-failure-cooldown",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
failureCode: "qmd-unavailable",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
|
||||
if (pending) {
|
||||
await pending.promise;
|
||||
return await getMemorySearchManager(params);
|
||||
return finish(await getMemorySearchManager(params), {
|
||||
backend: "qmd",
|
||||
managerCacheState: "pending-create-wait",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
});
|
||||
}
|
||||
|
||||
let pendingFailureReason: string | undefined;
|
||||
@@ -309,11 +399,25 @@ export async function getMemorySearchManager(params: {
|
||||
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
|
||||
const manager = await pendingCreate.promise;
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
|
||||
? 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",
|
||||
});
|
||||
}
|
||||
|
||||
return await getBuiltinMemorySearchManager(params);
|
||||
return finish(await getBuiltinMemorySearchManager(params), {
|
||||
backend: "builtin",
|
||||
});
|
||||
}
|
||||
|
||||
async function getBuiltinMemorySearchManagerAfterQmdFailure(
|
||||
|
||||
@@ -67,18 +67,28 @@ export async function getMemoryManagerContextWithPurpose(params: {
|
||||
}): Promise<
|
||||
| {
|
||||
manager: NonNullable<MemorySearchManagerResult["manager"]>;
|
||||
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
|
||||
}
|
||||
| {
|
||||
error: string | undefined;
|
||||
}
|
||||
> {
|
||||
const { getMemorySearchManager } = await loadMemoryToolRuntime();
|
||||
const { manager, error } = await getMemorySearchManager({
|
||||
const startedAt = Date.now();
|
||||
const { manager, debug, error } = await getMemorySearchManager({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
purpose: params.purpose,
|
||||
});
|
||||
return manager ? { manager } : { error };
|
||||
return manager
|
||||
? {
|
||||
manager,
|
||||
debug: {
|
||||
...debug,
|
||||
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
|
||||
},
|
||||
}
|
||||
: { error };
|
||||
}
|
||||
|
||||
export function createMemoryTool(params: {
|
||||
|
||||
@@ -422,6 +422,14 @@ 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 [
|
||||
{
|
||||
@@ -470,6 +478,18 @@ 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");
|
||||
@@ -479,6 +499,94 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -415,6 +415,7 @@ 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;
|
||||
@@ -471,13 +472,20 @@ 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;
|
||||
@@ -506,6 +514,8 @@ 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) {
|
||||
@@ -522,6 +532,8 @@ 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);
|
||||
}
|
||||
@@ -581,6 +593,7 @@ 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,
|
||||
@@ -589,7 +602,10 @@ export function createMemorySearchTool(options: {
|
||||
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
|
||||
: "n/a",
|
||||
fallback: latestDebug?.fallback,
|
||||
searchMs: Math.max(0, Date.now() - searchStartedAt),
|
||||
managerMs,
|
||||
searchMs,
|
||||
managerCacheState,
|
||||
qmd: latestDebug?.qmd,
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
@@ -620,6 +636,14 @@ 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,
|
||||
|
||||
@@ -168,23 +168,42 @@ 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(2);
|
||||
expect(scoped).toHaveLength(3);
|
||||
expect(scoped.map((request) => request.plannedToolName ?? "result")).toEqual([
|
||||
"sessions_spawn",
|
||||
"sessions_spawn",
|
||||
"result",
|
||||
]);
|
||||
|
||||
@@ -120,6 +120,7 @@ type RuntimeParityTranscriptRecord = {
|
||||
};
|
||||
|
||||
type RuntimeParityMockRequestSnapshot = {
|
||||
prompt?: string;
|
||||
allInputText?: string;
|
||||
plannedToolName?: string;
|
||||
plannedToolArgs?: unknown;
|
||||
@@ -759,14 +760,22 @@ function resolveRuntimeParityToolCalls(params: {
|
||||
function filterMockRequestsForParentPrompt(
|
||||
requests: RuntimeParityMockRequestSnapshot[],
|
||||
parentPrompt: string,
|
||||
parentPrompts: readonly string[] = [parentPrompt],
|
||||
) {
|
||||
const normalizedParentPrompt = normalizeTextForParity(parentPrompt);
|
||||
if (!normalizedParentPrompt) {
|
||||
const normalizedParentPrompts = parentPrompts
|
||||
.map(normalizeTextForParity)
|
||||
.filter((prompt) => prompt.length > 0);
|
||||
if (normalizedParentPrompts.length === 0) {
|
||||
return requests;
|
||||
}
|
||||
const matching = requests.filter((request) =>
|
||||
normalizeTextForParity(request.allInputText ?? "").includes(normalizedParentPrompt),
|
||||
);
|
||||
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));
|
||||
});
|
||||
return matching.length > 0 ? matching : requests;
|
||||
}
|
||||
|
||||
@@ -966,6 +975,7 @@ 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) {
|
||||
@@ -991,6 +1001,7 @@ 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,
|
||||
@@ -998,7 +1009,7 @@ async function loadRuntimeParityMockToolCalls(
|
||||
}),
|
||||
);
|
||||
return resolveToolCallOrderFromMockRequests(
|
||||
filterMockRequestsForParentPrompt(requests, parentPrompt),
|
||||
filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -1015,12 +1026,16 @@ export async function captureRuntimeParityCell(
|
||||
});
|
||||
const transcriptRecords = buildTranscriptRecords(transcriptBytes);
|
||||
const transcriptToolCalls = resolveToolCallOrder(transcriptRecords);
|
||||
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 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 gatewayLogs = params.gateway.logs?.();
|
||||
const sentinelFindings = [
|
||||
...scanGatewayLogSentinels(gatewayLogs),
|
||||
|
||||
@@ -55,11 +55,39 @@ 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. */
|
||||
|
||||
@@ -108,7 +108,7 @@ flow:
|
||||
- lambda:
|
||||
params: [text]
|
||||
expr: "config.expectedReplyGroups.every((group) => group.some((needle) => normalizeLowercaseStringOrEmpty(text).includes(needle)))"
|
||||
- expr: "env.providerMode === 'mock-openai' ? 10000 : 30000"
|
||||
- expr: "30000"
|
||||
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
@@ -240,7 +240,11 @@ flow:
|
||||
message:
|
||||
expr: "lastError instanceof Error ? formatErrorMessage(lastError) : String(lastError ?? 'fanout retry exhausted')"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
# 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'"
|
||||
then:
|
||||
- forEach:
|
||||
items:
|
||||
@@ -253,5 +257,5 @@ flow:
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "String(candidate.text ?? '').trim() === childCompletionMarker"
|
||||
- 10000
|
||||
- 30000
|
||||
detailsExpr: "details"
|
||||
|
||||
@@ -26,7 +26,9 @@ scenario:
|
||||
config:
|
||||
sessionKey: agent:qa:long-context-cache-stability
|
||||
fixtureFile: large-cache-fixture.txt
|
||||
cacheEvidenceNeedle: CACHE-FIXTURE-0550
|
||||
cacheEvidenceNeedle: CACHE-FIXTURE-0050
|
||||
cacheEvidenceLine: "CACHE-FIXTURE-0050: stable tool-result evidence for prompt-cache reuse across long sessions."
|
||||
followupPromptNeedle: Using the already-read
|
||||
warmupMarker: QA-LARGE-CACHE-WARMUP-OK
|
||||
hitMarker: QA-LARGE-CACHE-HIT-OK
|
||||
|
||||
@@ -84,8 +86,17 @@ 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 || 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)...')))))"
|
||||
expr: "!env.mock || (hasCappedReadEvidence && hasFollowupCacheEvidence)"
|
||||
message:
|
||||
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)...') })))}`"
|
||||
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) })) })}`"
|
||||
detailsExpr: "outbound?.text ?? config.hitMarker"
|
||||
|
||||
@@ -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", 10386),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10382),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5211),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
|
||||
@@ -73,7 +73,9 @@ 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);
|
||||
});
|
||||
|
||||
@@ -332,47 +334,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,13 +50,6 @@ 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;
|
||||
}
|
||||
@@ -554,7 +547,6 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
|
||||
result?: unknown;
|
||||
hookResult?: unknown;
|
||||
isError?: boolean;
|
||||
allowExplicitSourceRoute?: boolean;
|
||||
}): boolean {
|
||||
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
|
||||
return false;
|
||||
@@ -563,12 +555,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
|
||||
return false;
|
||||
}
|
||||
const args = asRecord(params.args);
|
||||
const sourceRouteReplyAction =
|
||||
params.allowExplicitSourceRoute === true && isMessageToolSourceReplyActionName(args.action);
|
||||
if (!isMessageToolSendActionName(args.action) && !sourceRouteReplyAction) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitMessageRoute(args) && params.allowExplicitSourceRoute !== true) {
|
||||
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
|
||||
return false;
|
||||
}
|
||||
return isDeliveredMessagingToolResult(params);
|
||||
|
||||
@@ -641,7 +641,7 @@ async function runEmbeddedAgentInternal(
|
||||
...paramsBase,
|
||||
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
|
||||
sessionId: runSessionTarget.sessionId,
|
||||
sessionKey: effectiveSessionKey ?? runSessionTarget.sessionKey,
|
||||
sessionKey: normalizeOptionalString(effectiveSessionKey ?? runSessionTarget.sessionKey),
|
||||
sessionFile: runSessionTarget.sessionFile,
|
||||
};
|
||||
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||
|
||||
@@ -28,10 +28,6 @@ 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";
|
||||
|
||||
@@ -131,7 +131,7 @@ type FacadeModule = {
|
||||
getMemorySearchManager: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
purpose?: "default" | "status";
|
||||
purpose?: "default" | "status" | "cli";
|
||||
}) => Promise<{
|
||||
manager: MemorySearchManager | null;
|
||||
error?: string;
|
||||
|
||||
@@ -102,6 +102,21 @@ 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: {
|
||||
|
||||
Reference in New Issue
Block a user