mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(doctor): check every configured agent (#111758)
This commit is contained in:
@@ -3,8 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/workspace"));
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() =>
|
||||
vi.fn<(_cfg: OpenClawConfig, agentId: string) => string>(() => "/tmp/workspace"),
|
||||
);
|
||||
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
|
||||
const listAgentIds = vi.hoisted(() => vi.fn(() => ["main"]));
|
||||
const resolveBootstrapContextForRun = vi.hoisted(() => vi.fn());
|
||||
const resolveBootstrapMaxChars = vi.hoisted(() => vi.fn(() => 20_000));
|
||||
const resolveBootstrapTotalMaxChars = vi.hoisted(() => vi.fn(() => 150_000));
|
||||
@@ -14,6 +17,7 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
listAgentIds,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
}));
|
||||
@@ -37,6 +41,7 @@ describe("noteBootstrapFileSize", () => {
|
||||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
});
|
||||
listAgentIds.mockReturnValue(["main"]);
|
||||
});
|
||||
|
||||
it("emits a warning when bootstrap files are truncated", async () => {
|
||||
@@ -69,6 +74,7 @@ describe("noteBootstrapFileSize", () => {
|
||||
|
||||
it("threads the default agent id through bootstrap size resolution", async () => {
|
||||
resolveDefaultAgentId.mockReturnValueOnce("custom-agent");
|
||||
listAgentIds.mockReturnValueOnce(["custom-agent"]);
|
||||
resolveBootstrapContextForRun.mockResolvedValue({
|
||||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
@@ -96,4 +102,32 @@ describe("noteBootstrapFileSize", () => {
|
||||
await noteBootstrapFileSize({} as OpenClawConfig);
|
||||
expect(note).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels a secondary agent whose bootstrap files exceed the limit", async () => {
|
||||
listAgentIds.mockReturnValue(["main", "secondary"]);
|
||||
resolveAgentWorkspaceDir.mockImplementation((_cfg, agentId) => `/tmp/${agentId}`);
|
||||
resolveBootstrapContextForRun.mockImplementation(async ({ agentId }) => ({
|
||||
bootstrapFiles:
|
||||
agentId === "secondary"
|
||||
? [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: "/tmp/secondary/AGENTS.md",
|
||||
content: "a".repeat(25_000),
|
||||
missing: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
contextFiles:
|
||||
agentId === "secondary"
|
||||
? [{ path: "/tmp/secondary/AGENTS.md", content: "a".repeat(20_000) }]
|
||||
: [],
|
||||
}));
|
||||
|
||||
await noteBootstrapFileSize({} as OpenClawConfig);
|
||||
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
expect(note.mock.calls[0]?.[0]).toContain('Agent "secondary":');
|
||||
expect(resolveBootstrapContextForRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Doctor note for workspace bootstrap file size and truncation risk. */
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import {
|
||||
buildBootstrapInjectionStats,
|
||||
analyzeBootstrapBudget,
|
||||
@@ -38,76 +42,90 @@ function formatCauses(causes: Array<"per-file-limit" | "total-limit">): string {
|
||||
*/
|
||||
export async function noteBootstrapFileSize(cfg: OpenClawConfig) {
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, defaultAgentId);
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(cfg, defaultAgentId);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(cfg, defaultAgentId);
|
||||
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
agentId: defaultAgentId,
|
||||
});
|
||||
const stats = buildBootstrapInjectionStats({
|
||||
bootstrapFiles,
|
||||
injectedFiles: contextFiles,
|
||||
});
|
||||
const analysis = analyzeBootstrapBudget({
|
||||
files: stats,
|
||||
bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars,
|
||||
});
|
||||
if (!analysis.hasTruncation && analysis.nearLimitFiles.length === 0 && !analysis.totalNearLimit) {
|
||||
return analysis;
|
||||
}
|
||||
const agentIds = listAgentIds(cfg);
|
||||
const workspaces = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
}));
|
||||
let defaultAnalysis: ReturnType<typeof analyzeBootstrapBudget> | undefined;
|
||||
for (const { agentId, workspaceDir } of workspaces) {
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(cfg, agentId);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(cfg, agentId);
|
||||
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
agentId,
|
||||
});
|
||||
const stats = buildBootstrapInjectionStats({
|
||||
bootstrapFiles,
|
||||
injectedFiles: contextFiles,
|
||||
});
|
||||
const analysis = analyzeBootstrapBudget({
|
||||
files: stats,
|
||||
bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars,
|
||||
});
|
||||
if (agentId === defaultAgentId) {
|
||||
defaultAnalysis = analysis;
|
||||
}
|
||||
if (
|
||||
!analysis.hasTruncation &&
|
||||
analysis.nearLimitFiles.length === 0 &&
|
||||
!analysis.totalNearLimit
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (analysis.hasTruncation) {
|
||||
lines.push("Workspace bootstrap files exceed limits and will be truncated:");
|
||||
for (const file of analysis.truncatedFiles) {
|
||||
const truncatedChars = Math.max(0, file.rawChars - file.injectedChars);
|
||||
const lines: string[] = agentIds.length > 1 ? [`Agent "${agentId}":`] : [];
|
||||
if (analysis.hasTruncation) {
|
||||
lines.push("Workspace bootstrap files exceed limits and will be truncated:");
|
||||
for (const file of analysis.truncatedFiles) {
|
||||
const truncatedChars = Math.max(0, file.rawChars - file.injectedChars);
|
||||
lines.push(
|
||||
`- ${file.name}: ${formatInt(file.rawChars)} raw / ${formatInt(file.injectedChars)} injected (${formatPercent(truncatedChars, file.rawChars)} truncated; ${formatCauses(file.causes)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push("Workspace bootstrap files are near configured limits:");
|
||||
}
|
||||
|
||||
const nonTruncatedNearLimit = analysis.nearLimitFiles.filter((file) => !file.truncated);
|
||||
if (nonTruncatedNearLimit.length > 0) {
|
||||
for (const file of nonTruncatedNearLimit) {
|
||||
lines.push(
|
||||
`- ${file.name}: ${formatInt(file.rawChars)} chars (${formatPercent(file.rawChars, bootstrapMaxChars)} of max/file ${formatInt(bootstrapMaxChars)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Total bootstrap injected chars: ${formatInt(analysis.totals.injectedChars)} (${formatPercent(analysis.totals.injectedChars, bootstrapTotalMaxChars)} of max/total ${formatInt(bootstrapTotalMaxChars)}).`,
|
||||
);
|
||||
lines.push(
|
||||
`Total bootstrap raw chars (before truncation): ${formatInt(analysis.totals.rawChars)}.`,
|
||||
);
|
||||
|
||||
const needsPerFileTip =
|
||||
analysis.truncatedFiles.some((file) => file.causes.includes("per-file-limit")) ||
|
||||
analysis.nearLimitFiles.length > 0;
|
||||
const needsTotalTip =
|
||||
analysis.truncatedFiles.some((file) => file.causes.includes("total-limit")) ||
|
||||
analysis.totalNearLimit;
|
||||
if (needsPerFileTip || needsTotalTip) {
|
||||
lines.push("");
|
||||
}
|
||||
if (needsPerFileTip) {
|
||||
lines.push(
|
||||
`- ${file.name}: ${formatInt(file.rawChars)} raw / ${formatInt(file.injectedChars)} injected (${formatPercent(truncatedChars, file.rawChars)} truncated; ${formatCauses(file.causes)})`,
|
||||
"- Tip: tune `agents.list[].bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push("Workspace bootstrap files are near configured limits:");
|
||||
}
|
||||
|
||||
const nonTruncatedNearLimit = analysis.nearLimitFiles.filter((file) => !file.truncated);
|
||||
if (nonTruncatedNearLimit.length > 0) {
|
||||
for (const file of nonTruncatedNearLimit) {
|
||||
if (needsTotalTip) {
|
||||
lines.push(
|
||||
`- ${file.name}: ${formatInt(file.rawChars)} chars (${formatPercent(file.rawChars, bootstrapMaxChars)} of max/file ${formatInt(bootstrapMaxChars)})`,
|
||||
"- Tip: tune `agents.list[].bootstrapTotalMaxChars` for this agent, or `agents.defaults.bootstrapTotalMaxChars` as fallback, for total-budget limits.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Total bootstrap injected chars: ${formatInt(analysis.totals.injectedChars)} (${formatPercent(analysis.totals.injectedChars, bootstrapTotalMaxChars)} of max/total ${formatInt(bootstrapTotalMaxChars)}).`,
|
||||
);
|
||||
lines.push(
|
||||
`Total bootstrap raw chars (before truncation): ${formatInt(analysis.totals.rawChars)}.`,
|
||||
);
|
||||
|
||||
const needsPerFileTip =
|
||||
analysis.truncatedFiles.some((file) => file.causes.includes("per-file-limit")) ||
|
||||
analysis.nearLimitFiles.length > 0;
|
||||
const needsTotalTip =
|
||||
analysis.truncatedFiles.some((file) => file.causes.includes("total-limit")) ||
|
||||
analysis.totalNearLimit;
|
||||
if (needsPerFileTip || needsTotalTip) {
|
||||
lines.push("");
|
||||
note(lines.join("\n"), "Bootstrap file size");
|
||||
}
|
||||
if (needsPerFileTip) {
|
||||
lines.push(
|
||||
"- Tip: tune `agents.list[].bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.",
|
||||
);
|
||||
}
|
||||
if (needsTotalTip) {
|
||||
lines.push(
|
||||
"- Tip: tune `agents.list[].bootstrapTotalMaxChars` for this agent, or `agents.defaults.bootstrapTotalMaxChars` as fallback, for total-budget limits.",
|
||||
);
|
||||
}
|
||||
|
||||
note(lines.join("\n"), "Bootstrap file size");
|
||||
return analysis;
|
||||
return defaultAnalysis;
|
||||
}
|
||||
|
||||
@@ -288,4 +288,44 @@ Add short tasks below the comments only when you want the agent to check somethi
|
||||
"Doctor changes",
|
||||
);
|
||||
});
|
||||
|
||||
it("labels and repairs only the secondary agent with a stale template", async () => {
|
||||
const main = await makeWorkspaceWithHeartbeat("# Main heartbeat task\n");
|
||||
const secondary = await makeWorkspaceWithHeartbeat(`\`\`\`markdown
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
\`\`\`
|
||||
`);
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: main.workspaceDir },
|
||||
{ id: "secondary", workspace: secondary.workspaceDir },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const findings = await collectHeartbeatTemplateHealthFindings(cfg);
|
||||
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Agent "secondary"'),
|
||||
path: secondary.heartbeatPath,
|
||||
target: "secondary",
|
||||
}),
|
||||
]);
|
||||
|
||||
await maybeRepairHeartbeatTemplate({ cfg, shouldRepair: true });
|
||||
|
||||
await expect(fs.readFile(main.heartbeatPath, "utf-8")).resolves.toBe("# Main heartbeat task\n");
|
||||
const cleanTemplate = await fs.readFile(
|
||||
path.resolve("src", "agents", "templates", "HEARTBEAT.md"),
|
||||
"utf-8",
|
||||
);
|
||||
await expect(fs.readFile(secondary.heartbeatPath, "utf-8")).resolves.toBe(cleanTemplate);
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Agent "secondary"'),
|
||||
"Doctor changes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { resolveWorkspaceTemplateDir } from "../agents/workspace-templates.js";
|
||||
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -128,6 +128,8 @@ async function readCleanHeartbeatTemplate(): Promise<string> {
|
||||
}
|
||||
|
||||
function heartbeatTemplateAnalysisToHealthFinding(
|
||||
agentId: string,
|
||||
labelAgent: boolean,
|
||||
heartbeatPath: string,
|
||||
analysis: Exclude<HeartbeatTemplateRepairAnalysis, { status: "clean" }>,
|
||||
): HealthFinding {
|
||||
@@ -135,9 +137,9 @@ function heartbeatTemplateAnalysisToHealthFinding(
|
||||
return {
|
||||
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
|
||||
severity: "warning",
|
||||
message:
|
||||
"HEARTBEAT.md contains an older heartbeat template wrapper plus custom or unrecognized content.",
|
||||
message: `${labelAgent ? `Agent "${agentId}": ` : ""}HEARTBEAT.md contains an older heartbeat template wrapper plus custom or unrecognized content.`,
|
||||
path: heartbeatPath,
|
||||
...(labelAgent ? { target: agentId } : {}),
|
||||
requirement: "legacy-template-with-custom-content",
|
||||
fixHint: "Remove the fenced template and Related lines manually if they are not intentional.",
|
||||
};
|
||||
@@ -145,8 +147,9 @@ function heartbeatTemplateAnalysisToHealthFinding(
|
||||
return {
|
||||
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: "HEARTBEAT.md contains an older heartbeat documentation template.",
|
||||
message: `${labelAgent ? `Agent "${agentId}": ` : ""}HEARTBEAT.md contains an older heartbeat documentation template.`,
|
||||
path: heartbeatPath,
|
||||
...(labelAgent ? { target: agentId } : {}),
|
||||
requirement: "legacy-template",
|
||||
fixHint: 'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
|
||||
};
|
||||
@@ -159,33 +162,42 @@ export async function collectHeartbeatTemplateHealthFindings(
|
||||
readFile?: (filePath: string) => Promise<string>;
|
||||
},
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
const agentIds = listAgentIds(cfg);
|
||||
const labelAgents = agentIds.length > 1;
|
||||
const targets = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
heartbeatPath: path.join(resolveAgentWorkspaceDir(cfg, agentId), DEFAULT_HEARTBEAT_FILENAME),
|
||||
}));
|
||||
const readFile = deps?.readFile ?? ((filePath: string) => fs.readFile(filePath, "utf-8"));
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(heartbeatPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
const findings: HealthFinding[] = [];
|
||||
for (const { agentId, heartbeatPath } of targets) {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(heartbeatPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Could not inspect HEARTBEAT.md: ${formatErrorMessage(error)}`,
|
||||
message: `${labelAgents ? `Agent "${agentId}": ` : ""}Could not inspect HEARTBEAT.md: ${formatErrorMessage(error)}`,
|
||||
path: heartbeatPath,
|
||||
...(labelAgents ? { target: agentId } : {}),
|
||||
requirement: "inspect-failed",
|
||||
fixHint: "Check file permissions, then rerun doctor.",
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const analysis = analyzeHeartbeatTemplateForRepair(content);
|
||||
if (analysis.status === "clean") {
|
||||
return [];
|
||||
const analysis = analyzeHeartbeatTemplateForRepair(content);
|
||||
if (analysis.status !== "clean") {
|
||||
findings.push(
|
||||
heartbeatTemplateAnalysisToHealthFinding(agentId, labelAgents, heartbeatPath, analysis),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [heartbeatTemplateAnalysisToHealthFinding(heartbeatPath, analysis)];
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Replaces known dirty heartbeat templates with the clean runtime template when repair is enabled. */
|
||||
@@ -193,58 +205,68 @@ export async function maybeRepairHeartbeatTemplate(params: {
|
||||
cfg: OpenClawConfig;
|
||||
shouldRepair: boolean;
|
||||
}): Promise<void> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
|
||||
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(heartbeatPath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
return;
|
||||
const agentIds = listAgentIds(params.cfg);
|
||||
const labelAgents = agentIds.length > 1;
|
||||
const targets = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
heartbeatPath: path.join(
|
||||
resolveAgentWorkspaceDir(params.cfg, agentId),
|
||||
DEFAULT_HEARTBEAT_FILENAME,
|
||||
),
|
||||
}));
|
||||
for (const { agentId, heartbeatPath } of targets) {
|
||||
const prefix = labelAgents ? `Agent "${agentId}": ` : "";
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(heartbeatPath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
note(
|
||||
`${prefix}Could not inspect ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
|
||||
"Heartbeat template",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
note(
|
||||
`Could not inspect ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
|
||||
"Heartbeat template",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = analyzeHeartbeatTemplateForRepair(content);
|
||||
if (analysis.status === "clean") {
|
||||
return;
|
||||
}
|
||||
if (analysis.status === "dirty-template-with-custom-content") {
|
||||
note(
|
||||
[
|
||||
`${shortenHomePath(heartbeatPath)} contains an older heartbeat template wrapper plus custom or unrecognized content.`,
|
||||
"Doctor left it unchanged so it does not delete user tasks. Remove the fenced template and Related lines manually if they are not intentional.",
|
||||
].join("\n"),
|
||||
"Heartbeat template",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!params.shouldRepair) {
|
||||
note(
|
||||
[
|
||||
`${shortenHomePath(heartbeatPath)} contains an older heartbeat documentation template.`,
|
||||
'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
|
||||
].join("\n"),
|
||||
"Heartbeat template",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const analysis = analyzeHeartbeatTemplateForRepair(content);
|
||||
if (analysis.status === "clean") {
|
||||
continue;
|
||||
}
|
||||
if (analysis.status === "dirty-template-with-custom-content") {
|
||||
note(
|
||||
[
|
||||
`${prefix}${shortenHomePath(heartbeatPath)} contains an older heartbeat template wrapper plus custom or unrecognized content.`,
|
||||
"Doctor left it unchanged so it does not delete user tasks. Remove the fenced template and Related lines manually if they are not intentional.",
|
||||
].join("\n"),
|
||||
"Heartbeat template",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!params.shouldRepair) {
|
||||
note(
|
||||
[
|
||||
`${prefix}${shortenHomePath(heartbeatPath)} contains an older heartbeat documentation template.`,
|
||||
'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
|
||||
].join("\n"),
|
||||
"Heartbeat template",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanTemplate = await readCleanHeartbeatTemplate();
|
||||
await writeTextAtomic(heartbeatPath, cleanTemplate, { mode: 0o600 });
|
||||
note(
|
||||
`Replaced ${shortenHomePath(heartbeatPath)} with the clean heartbeat template.`,
|
||||
"Doctor changes",
|
||||
);
|
||||
} catch (error) {
|
||||
note(
|
||||
`Could not repair ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
|
||||
"Heartbeat template",
|
||||
);
|
||||
try {
|
||||
const cleanTemplate = await readCleanHeartbeatTemplate();
|
||||
await writeTextAtomic(heartbeatPath, cleanTemplate, { mode: 0o600 });
|
||||
note(
|
||||
`${prefix}Replaced ${shortenHomePath(heartbeatPath)} with the clean heartbeat template.`,
|
||||
"Doctor changes",
|
||||
);
|
||||
} catch (error) {
|
||||
note(
|
||||
`${prefix}Could not repair ${shortenHomePath(heartbeatPath)}: ${formatErrorMessage(error)}`,
|
||||
"Heartbeat template",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@ import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "agent-default"));
|
||||
const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default/workspace"));
|
||||
const listAgentIds = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(cfg: { agents?: { list?: Array<{ id: string }> } }) =>
|
||||
cfg.agents?.list?.map((agent) => agent.id) ?? ["agent-default"],
|
||||
),
|
||||
);
|
||||
const resolveAgentDir = vi.hoisted(() =>
|
||||
vi.fn<(_cfg: OpenClawConfig, agentId: string) => string>(() => "/tmp/agent-default"),
|
||||
);
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() =>
|
||||
vi.fn<(_cfg: OpenClawConfig, agentId: string) => string>(() => "/tmp/agent-default/workspace"),
|
||||
);
|
||||
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
|
||||
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
|
||||
const hasAnyAuthProfileStoreSource = vi.hoisted(() => vi.fn(() => true));
|
||||
@@ -30,6 +40,7 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
listAgentIds,
|
||||
resolveDefaultAgentId,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -169,6 +180,10 @@ describe("noteMemorySearchHealth", () => {
|
||||
beforeEach(() => {
|
||||
note.mockClear();
|
||||
resolveDefaultAgentId.mockClear();
|
||||
listAgentIds.mockImplementation(
|
||||
(config: { agents?: { list?: Array<{ id: string }> } }) =>
|
||||
config.agents?.list?.map((agent) => agent.id) ?? ["agent-default"],
|
||||
);
|
||||
resolveAgentDir.mockClear();
|
||||
resolveAgentWorkspaceDir.mockClear();
|
||||
resolveMemorySearchConfig.mockReset();
|
||||
@@ -1632,10 +1647,30 @@ describe("noteMemorySearchHealth", () => {
|
||||
|
||||
await noteMemorySearchHealth(cfg);
|
||||
|
||||
expect(noteWorkspaceMemoryHealth).toHaveBeenCalledWith(cfg);
|
||||
expect(noteWorkspaceMemoryHealth).toHaveBeenCalledWith(cfg, {
|
||||
agentId: "agent-default",
|
||||
workspaceDir: "/tmp/agent-default/workspace",
|
||||
labelAgent: false,
|
||||
});
|
||||
const workspaceNote = note.mock.calls.find(([, title]) => title === "Workspace memory");
|
||||
expect(workspaceNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels memory readiness failures for a secondary agent", async () => {
|
||||
listAgentIds.mockReturnValue(["agent-default", "secondary"]);
|
||||
resolveAgentDir.mockImplementation((_cfg, agentId) => `/tmp/${agentId}`);
|
||||
resolveAgentWorkspaceDir.mockImplementation((_cfg, agentId) => `/tmp/${agentId}/workspace`);
|
||||
resolveMemorySearchConfig.mockImplementation((_cfg, agentId) =>
|
||||
agentId === "agent-default" ? { provider: "none", local: {}, remote: {} } : undefined,
|
||||
);
|
||||
|
||||
await noteMemorySearchHealth(cfg, { includeWorkspaceMemoryHealth: false });
|
||||
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
expect(firstNoteMessage()).toBe(
|
||||
'Agent "secondary": Remember across conversations is effectively enabled for agent "secondary", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory recall doctor integration", () => {
|
||||
@@ -1643,6 +1678,10 @@ describe("memory recall doctor integration", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
note.mockClear();
|
||||
listAgentIds.mockImplementation(
|
||||
(config: { agents?: { list?: Array<{ id: string }> } }) =>
|
||||
config.agents?.list?.map((agent) => agent.id) ?? ["agent-default"],
|
||||
);
|
||||
resetMemoryRecallMocks();
|
||||
});
|
||||
|
||||
@@ -1735,7 +1774,15 @@ describe("memory recall doctor integration", () => {
|
||||
|
||||
await maybeRepairMemoryRecallHealth({ cfg, prompter });
|
||||
|
||||
expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({ cfg, prompter });
|
||||
expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
prompter,
|
||||
scope: {
|
||||
agentId: "agent-default",
|
||||
workspaceDir: "/tmp/agent-default/workspace",
|
||||
labelAgent: false,
|
||||
},
|
||||
});
|
||||
expect(prompter.confirmRuntimeRepair).toHaveBeenCalled();
|
||||
expect(repairShortTermPromotionArtifacts).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/agent-default/workspace",
|
||||
@@ -1778,7 +1825,15 @@ describe("memory recall doctor integration", () => {
|
||||
|
||||
await maybeRepairMemoryRecallHealth({ cfg, prompter });
|
||||
|
||||
expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({ cfg, prompter });
|
||||
expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
prompter,
|
||||
scope: {
|
||||
agentId: "agent-default",
|
||||
workspaceDir: "/tmp/agent-default/workspace",
|
||||
labelAgent: false,
|
||||
},
|
||||
});
|
||||
expect(prompter.confirmRuntimeRepair).toHaveBeenCalled();
|
||||
expect(repairDreamingArtifacts).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/agent-default/workspace",
|
||||
@@ -1788,6 +1843,63 @@ describe("memory recall doctor integration", () => {
|
||||
expect(message).toContain("archived session corpus");
|
||||
expect(message).toContain("archived session-ingestion state");
|
||||
});
|
||||
|
||||
it("audits and repairs each agent with isolated managers and paths", async () => {
|
||||
getActiveMemorySearchManager.mockClear();
|
||||
listAgentIds.mockReturnValue(["agent-default", "secondary"]);
|
||||
resolveAgentDir.mockImplementation((_cfg, agentId) => `/tmp/${agentId}`);
|
||||
resolveAgentWorkspaceDir.mockImplementation((_cfg, agentId) => `/tmp/${agentId}/workspace`);
|
||||
const closes = new Map<string, ReturnType<typeof vi.fn>>();
|
||||
getActiveMemorySearchManager.mockImplementation(async ({ agentId }) => {
|
||||
const close = vi.fn(async () => {});
|
||||
closes.set(agentId, close);
|
||||
return {
|
||||
manager: {
|
||||
status: () => ({ workspaceDir: `/tmp/${agentId}/workspace`, backend: "builtin" }),
|
||||
close,
|
||||
},
|
||||
};
|
||||
});
|
||||
auditShortTermPromotionArtifacts.mockImplementation(async ({ workspaceDir }) => ({
|
||||
storePath: `${workspaceDir}/memory/.dreams/short-term-recall.json`,
|
||||
lockPath: `${workspaceDir}/memory/.dreams/short-term-promotion.lock`,
|
||||
exists: true,
|
||||
entryCount: 1,
|
||||
promotedCount: 0,
|
||||
spacedEntryCount: 0,
|
||||
conceptTaggedEntryCount: 1,
|
||||
invalidEntryCount: workspaceDir.includes("secondary") ? 1 : 0,
|
||||
issues: workspaceDir.includes("secondary")
|
||||
? [
|
||||
{
|
||||
severity: "warn",
|
||||
code: "recall-store-invalid",
|
||||
message: "Secondary recall is invalid.",
|
||||
fixable: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
repairShortTermPromotionArtifacts.mockResolvedValue({
|
||||
changed: true,
|
||||
removedInvalidEntries: 1,
|
||||
removedOverflowEntries: 0,
|
||||
rewroteStore: true,
|
||||
removedStaleLock: false,
|
||||
});
|
||||
const prompter = createPrompter();
|
||||
|
||||
await maybeRepairMemoryRecallHealth({ cfg, prompter });
|
||||
|
||||
expect(getActiveMemorySearchManager).toHaveBeenCalledTimes(2);
|
||||
expect(closes.get("agent-default")).toHaveBeenCalledOnce();
|
||||
expect(closes.get("secondary")).toHaveBeenCalledOnce();
|
||||
expect(repairShortTermPromotionArtifacts).toHaveBeenCalledTimes(1);
|
||||
expect(repairShortTermPromotionArtifacts).toHaveBeenCalledWith({
|
||||
workspaceDir: "/tmp/secondary/workspace",
|
||||
});
|
||||
expect(String(note.mock.calls.at(-1)?.[0])).toContain('Agent "secondary":');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRootMemoryFilesWarning", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatByteSize } from "@openclaw/normalization-core";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
@@ -59,6 +60,24 @@ type RuntimeMemoryAuditContext = {
|
||||
qmdCollections?: number;
|
||||
};
|
||||
|
||||
type MemoryDoctorAgentScope = {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
};
|
||||
|
||||
function resolveMemoryDoctorAgentScopes(cfg: OpenClawConfig): MemoryDoctorAgentScope[] {
|
||||
return listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
agentDir: resolveAgentDir(cfg, agentId),
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatAgentMessage(agentId: string, labelAgent: boolean, message: string): string {
|
||||
return `${labelAgent ? `Agent "${agentId}": ` : ""}${message}`;
|
||||
}
|
||||
|
||||
type MemoryEmbeddingProviderDoctorMetadata = {
|
||||
providerId: string;
|
||||
authProviderId: string;
|
||||
@@ -240,8 +259,8 @@ function isKeyOptionalMemoryProvider(providerId: string, cfg: OpenClawConfig): b
|
||||
|
||||
async function resolveRuntimeMemoryAuditContext(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Promise<RuntimeMemoryAuditContext | null> {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const result = await getActiveMemorySearchManager({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -301,33 +320,44 @@ function buildDreamingArtifactIssueNote(audit: DreamingArtifactsAuditSummary): s
|
||||
}
|
||||
|
||||
export async function noteMemoryRecallHealth(cfg: OpenClawConfig): Promise<void> {
|
||||
try {
|
||||
const context = await resolveRuntimeMemoryAuditContext(cfg);
|
||||
const workspaceDir = context?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
return;
|
||||
const scopes = resolveMemoryDoctorAgentScopes(cfg);
|
||||
const labelAgents = scopes.length > 1;
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const context = await resolveRuntimeMemoryAuditContext(cfg, scope.agentId);
|
||||
const workspaceDir = context?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
continue;
|
||||
}
|
||||
const audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
qmd:
|
||||
context?.backend === "qmd"
|
||||
? {
|
||||
dbPath: context.dbPath,
|
||||
collections: context.qmdCollections,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const message = buildMemoryRecallIssueNote(audit);
|
||||
if (message) {
|
||||
note(formatAgentMessage(scope.agentId, labelAgents, message), "Memory search");
|
||||
}
|
||||
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
const dreamingMessage = buildDreamingArtifactIssueNote(dreamingAudit);
|
||||
if (dreamingMessage) {
|
||||
note(formatAgentMessage(scope.agentId, labelAgents, dreamingMessage), "Memory search");
|
||||
}
|
||||
} catch (err) {
|
||||
note(
|
||||
formatAgentMessage(
|
||||
scope.agentId,
|
||||
labelAgents,
|
||||
`Memory recall audit could not be completed: ${formatErrorMessage(err)}`,
|
||||
),
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
const audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
qmd:
|
||||
context?.backend === "qmd"
|
||||
? {
|
||||
dbPath: context.dbPath,
|
||||
collections: context.qmdCollections,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const message = buildMemoryRecallIssueNote(audit);
|
||||
if (message) {
|
||||
note(message, "Memory search");
|
||||
}
|
||||
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
const dreamingMessage = buildDreamingArtifactIssueNote(dreamingAudit);
|
||||
if (dreamingMessage) {
|
||||
note(dreamingMessage, "Memory search");
|
||||
}
|
||||
} catch (err) {
|
||||
note(`Memory recall audit could not be completed: ${formatErrorMessage(err)}`, "Memory search");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,84 +365,111 @@ export async function maybeRepairMemoryRecallHealth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: DoctorPrompter;
|
||||
}): Promise<void> {
|
||||
await maybeRepairWorkspaceMemoryHealth(params);
|
||||
|
||||
try {
|
||||
const context = await resolveRuntimeMemoryAuditContext(params.cfg);
|
||||
const workspaceDir = context?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
return;
|
||||
}
|
||||
const audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
qmd:
|
||||
context?.backend === "qmd"
|
||||
? {
|
||||
dbPath: context.dbPath,
|
||||
collections: context.qmdCollections,
|
||||
}
|
||||
: undefined,
|
||||
const scopes = resolveMemoryDoctorAgentScopes(params.cfg);
|
||||
const labelAgents = scopes.length > 1;
|
||||
for (const scope of scopes) {
|
||||
await maybeRepairWorkspaceMemoryHealth({
|
||||
...params,
|
||||
scope: {
|
||||
agentId: scope.agentId,
|
||||
workspaceDir: scope.workspaceDir,
|
||||
labelAgent: labelAgents,
|
||||
},
|
||||
});
|
||||
const hasFixableRecallIssue = audit.issues.some((issue) => issue.fixable);
|
||||
if (hasFixableRecallIssue) {
|
||||
const approved = await params.prompter.confirmRuntimeRepair({
|
||||
message: "Normalize memory recall artifacts and remove stale promotion locks?",
|
||||
initialValue: true,
|
||||
try {
|
||||
const context = await resolveRuntimeMemoryAuditContext(params.cfg, scope.agentId);
|
||||
const workspaceDir = context?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
continue;
|
||||
}
|
||||
const audit = await auditShortTermPromotionArtifacts({
|
||||
workspaceDir,
|
||||
qmd:
|
||||
context?.backend === "qmd"
|
||||
? {
|
||||
dbPath: context.dbPath,
|
||||
collections: context.qmdCollections,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (approved) {
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
if (repair.changed) {
|
||||
const removedOverflowEntries = repair.removedOverflowEntries ?? 0;
|
||||
const details = [
|
||||
repair.removedInvalidEntries > 0
|
||||
? `-${repair.removedInvalidEntries} invalid entries`
|
||||
: null,
|
||||
removedOverflowEntries > 0 ? `-${removedOverflowEntries} overflow entries` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const lines = [
|
||||
"Memory recall artifacts repaired:",
|
||||
repair.rewroteStore ? `- rewrote recall store${details ? ` (${details})` : ""}` : null,
|
||||
repair.removedStaleLock ? "- removed stale promotion lock" : null,
|
||||
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
|
||||
].filter(Boolean);
|
||||
note(lines.join("\n"), "Doctor changes");
|
||||
const hasFixableRecallIssue = audit.issues.some((issue) => issue.fixable);
|
||||
if (hasFixableRecallIssue) {
|
||||
const approved = await params.prompter.confirmRuntimeRepair({
|
||||
message: formatAgentMessage(
|
||||
scope.agentId,
|
||||
labelAgents,
|
||||
"Normalize memory recall artifacts and remove stale promotion locks?",
|
||||
),
|
||||
initialValue: true,
|
||||
});
|
||||
if (approved) {
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
if (repair.changed) {
|
||||
const removedOverflowEntries = repair.removedOverflowEntries ?? 0;
|
||||
const details = [
|
||||
repair.removedInvalidEntries > 0
|
||||
? `-${repair.removedInvalidEntries} invalid entries`
|
||||
: null,
|
||||
removedOverflowEntries > 0 ? `-${removedOverflowEntries} overflow entries` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const lines = [
|
||||
"Memory recall artifacts repaired:",
|
||||
repair.rewroteStore
|
||||
? `- rewrote recall store${details ? ` (${details})` : ""}`
|
||||
: null,
|
||||
repair.removedStaleLock ? "- removed stale promotion lock" : null,
|
||||
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
|
||||
].filter(Boolean);
|
||||
note(
|
||||
formatAgentMessage(scope.agentId, labelAgents, lines.join("\n")),
|
||||
"Doctor changes",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
const hasFixableDreamingIssue = dreamingAudit.issues.some((issue) => issue.fixable);
|
||||
if (!hasFixableDreamingIssue) {
|
||||
return;
|
||||
const dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
||||
const hasFixableDreamingIssue = dreamingAudit.issues.some((issue) => issue.fixable);
|
||||
if (!hasFixableDreamingIssue) {
|
||||
continue;
|
||||
}
|
||||
const approvedDreamingRepair = await params.prompter.confirmRuntimeRepair({
|
||||
message: formatAgentMessage(
|
||||
scope.agentId,
|
||||
labelAgents,
|
||||
"Archive contaminated dreaming artifacts and reset derived dream corpus state?",
|
||||
),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!approvedDreamingRepair) {
|
||||
continue;
|
||||
}
|
||||
const dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
|
||||
if (!dreamingRepair.changed) {
|
||||
continue;
|
||||
}
|
||||
const lines = [
|
||||
"Dreaming artifacts repaired:",
|
||||
dreamingRepair.archivedSessionCorpus ? "- archived session corpus" : null,
|
||||
dreamingRepair.archivedSessionIngestion ? "- archived session-ingestion state" : null,
|
||||
dreamingRepair.archivedDreamsDiary ? "- archived dream diary" : null,
|
||||
dreamingRepair.archiveDir ? `- archive dir: ${dreamingRepair.archiveDir}` : null,
|
||||
...dreamingRepair.warnings.map((warning) => `- warning: ${warning}`),
|
||||
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
|
||||
].filter(Boolean);
|
||||
note(formatAgentMessage(scope.agentId, labelAgents, lines.join("\n")), "Doctor changes");
|
||||
} catch (err) {
|
||||
note(
|
||||
formatAgentMessage(
|
||||
scope.agentId,
|
||||
labelAgents,
|
||||
`Memory artifact repair could not be completed: ${formatErrorMessage(err)}`,
|
||||
),
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
const approvedDreamingRepair = await params.prompter.confirmRuntimeRepair({
|
||||
message: "Archive contaminated dreaming artifacts and reset derived dream corpus state?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!approvedDreamingRepair) {
|
||||
return;
|
||||
}
|
||||
const dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
|
||||
if (!dreamingRepair.changed) {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
"Dreaming artifacts repaired:",
|
||||
dreamingRepair.archivedSessionCorpus ? "- archived session corpus" : null,
|
||||
dreamingRepair.archivedSessionIngestion ? "- archived session-ingestion state" : null,
|
||||
dreamingRepair.archivedDreamsDiary ? "- archived dream diary" : null,
|
||||
dreamingRepair.archiveDir ? `- archive dir: ${dreamingRepair.archiveDir}` : null,
|
||||
...dreamingRepair.warnings.map((warning) => `- warning: ${warning}`),
|
||||
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
|
||||
].filter(Boolean);
|
||||
note(lines.join("\n"), "Doctor changes");
|
||||
} catch (err) {
|
||||
note(
|
||||
`Memory artifact repair could not be completed: ${formatErrorMessage(err)}`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,25 +498,6 @@ function hasActiveAlternateMemoryPluginSlot(cfg: OpenClawConfig): boolean {
|
||||
return entry.enabled === true || entry.config !== undefined;
|
||||
}
|
||||
|
||||
function listRememberAcrossConversationAgentIds(
|
||||
cfg: OpenClawConfig,
|
||||
defaultAgentId: string,
|
||||
): string[] {
|
||||
const configuredAgents = cfg.agents?.list ?? [];
|
||||
const enabledAgentIds = configuredAgents
|
||||
.filter((agent) => resolveRememberAcrossConversations(cfg, agent.id))
|
||||
.map((agent) => agent.id.trim());
|
||||
if (
|
||||
resolveRememberAcrossConversations(cfg, defaultAgentId) &&
|
||||
!enabledAgentIds.some(
|
||||
(agentId) => agentId.toLowerCase() === defaultAgentId.trim().toLowerCase(),
|
||||
)
|
||||
) {
|
||||
enabledAgentIds.push(defaultAgentId);
|
||||
}
|
||||
return [...new Set(enabledAgentIds)];
|
||||
}
|
||||
|
||||
function isActiveMemoryPluginAvailable(cfg: OpenClawConfig): boolean {
|
||||
const plugins = normalizePluginsConfig(cfg.plugins);
|
||||
if (!plugins.enabled || plugins.deny.includes("active-memory")) {
|
||||
@@ -498,45 +536,33 @@ function resolveActiveMemoryConversationRecallSupport(cfg: OpenClawConfig): {
|
||||
|
||||
function noteRememberAcrossConversationsHealth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
defaultAgentId: string;
|
||||
agentId: string;
|
||||
noteFn: typeof note;
|
||||
}): { defaultAgentEnabled: boolean } {
|
||||
const agentIds = listRememberAcrossConversationAgentIds(params.cfg, params.defaultAgentId);
|
||||
}): { enabled: boolean } {
|
||||
const enabled = resolveRememberAcrossConversations(params.cfg, params.agentId);
|
||||
if (!enabled) {
|
||||
return { enabled: false };
|
||||
}
|
||||
const activeMemoryAvailable = isActiveMemoryPluginAvailable(params.cfg);
|
||||
const conversationRecallSupport = resolveActiveMemoryConversationRecallSupport(params.cfg);
|
||||
for (const agentId of agentIds) {
|
||||
if (!activeMemoryAvailable) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${agentId}", but the Active Memory plugin is disabled. Enable the plugin or set memorySearch.rememberAcrossConversations to false.`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
if (activeMemoryAvailable && !conversationRecallSupport.providerSupported) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${agentId}", but the current memory provider does not support protected private transcript recall. Set memorySearch.rememberAcrossConversations to false or use that provider's own recall path; advanced Active Memory can still use its recall tools.`,
|
||||
"Memory search",
|
||||
);
|
||||
} else if (activeMemoryAvailable && !conversationRecallSupport.memorySearchAllowed) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${agentId}", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memorySearch.rememberAcrossConversations to false.`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
if (
|
||||
agentId.trim().toLowerCase() !== params.defaultAgentId.trim().toLowerCase() &&
|
||||
!resolveMemorySearchConfig(params.cfg, agentId)
|
||||
) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${agentId}", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
if (!activeMemoryAvailable) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${params.agentId}", but the Active Memory plugin is disabled. Enable the plugin or set memorySearch.rememberAcrossConversations to false.`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
return {
|
||||
defaultAgentEnabled: agentIds.some(
|
||||
(agentId) => agentId.trim().toLowerCase() === params.defaultAgentId.trim().toLowerCase(),
|
||||
),
|
||||
};
|
||||
if (activeMemoryAvailable && !conversationRecallSupport.providerSupported) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${params.agentId}", but the current memory provider does not support protected private transcript recall. Set memorySearch.rememberAcrossConversations to false or use that provider's own recall path; advanced Active Memory can still use its recall tools.`,
|
||||
"Memory search",
|
||||
);
|
||||
} else if (activeMemoryAvailable && !conversationRecallSupport.memorySearchAllowed) {
|
||||
params.noteFn(
|
||||
`Remember across conversations is effectively enabled for agent "${params.agentId}", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memorySearch.rememberAcrossConversations to false.`,
|
||||
"Memory search",
|
||||
);
|
||||
}
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,32 +571,57 @@ function noteRememberAcrossConversationsHealth(params: {
|
||||
* may spawn a short-lived probe process when `memory.backend=qmd` to verify
|
||||
* the configured `qmd` binary is available.
|
||||
*/
|
||||
type MemorySearchHealthOptions = {
|
||||
gatewayMemoryProbe?: {
|
||||
checked: boolean;
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
skipped?: boolean;
|
||||
runtimeFacts?: DoctorMemoryEmbeddingRuntimePayload;
|
||||
};
|
||||
noteFn?: typeof note;
|
||||
includeWorkspaceMemoryHealth?: boolean;
|
||||
skipQmdBinaryProbe?: boolean;
|
||||
skipAuthProfileResolution?: boolean;
|
||||
};
|
||||
|
||||
export async function noteMemorySearchHealth(
|
||||
cfg: OpenClawConfig,
|
||||
opts?: {
|
||||
gatewayMemoryProbe?: {
|
||||
checked: boolean;
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
skipped?: boolean;
|
||||
runtimeFacts?: DoctorMemoryEmbeddingRuntimePayload;
|
||||
};
|
||||
noteFn?: typeof note;
|
||||
includeWorkspaceMemoryHealth?: boolean;
|
||||
skipQmdBinaryProbe?: boolean;
|
||||
skipAuthProfileResolution?: boolean;
|
||||
},
|
||||
opts?: MemorySearchHealthOptions,
|
||||
): Promise<void> {
|
||||
const noteFn = opts?.noteFn ?? note;
|
||||
if (opts?.includeWorkspaceMemoryHealth !== false) {
|
||||
await noteWorkspaceMemoryHealth(cfg);
|
||||
const scopes = resolveMemoryDoctorAgentScopes(cfg);
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const labelAgents = scopes.length > 1;
|
||||
for (const scope of scopes) {
|
||||
if (opts?.includeWorkspaceMemoryHealth !== false) {
|
||||
await noteWorkspaceMemoryHealth(cfg, {
|
||||
agentId: scope.agentId,
|
||||
workspaceDir: scope.workspaceDir,
|
||||
labelAgent: labelAgents,
|
||||
});
|
||||
}
|
||||
const outputNote = opts?.noteFn ?? note;
|
||||
const noteFn: typeof note = (message, title) =>
|
||||
outputNote(formatAgentMessage(scope.agentId, labelAgents, String(message)), title);
|
||||
await noteMemorySearchHealthForAgent(cfg, scope, {
|
||||
...opts,
|
||||
noteFn,
|
||||
includeWorkspaceMemoryHealth: false,
|
||||
gatewayMemoryProbe: scope.agentId === defaultAgentId ? opts?.gatewayMemoryProbe : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
async function noteMemorySearchHealthForAgent(
|
||||
cfg: OpenClawConfig,
|
||||
scope: MemoryDoctorAgentScope,
|
||||
opts: MemorySearchHealthOptions,
|
||||
): Promise<void> {
|
||||
const { agentId, agentDir, workspaceDir } = scope;
|
||||
const noteFn = opts.noteFn ?? note;
|
||||
const recallHealth = noteRememberAcrossConversationsHealth({
|
||||
cfg,
|
||||
defaultAgentId: agentId,
|
||||
agentId,
|
||||
noteFn,
|
||||
});
|
||||
const resolved = resolveMemorySearchConfig(cfg, agentId);
|
||||
@@ -578,7 +629,7 @@ export async function noteMemorySearchHealth(
|
||||
|
||||
if (!resolved) {
|
||||
noteFn(
|
||||
recallHealth.defaultAgentEnabled
|
||||
recallHealth.enabled
|
||||
? `Remember across conversations is effectively enabled for agent "${agentId}", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.`
|
||||
: "Memory search is explicitly disabled (enabled: false).",
|
||||
"Memory search",
|
||||
@@ -606,7 +657,7 @@ export async function noteMemorySearchHealth(
|
||||
const qmdCheck = await checkQmdBinaryAvailability({
|
||||
command: backendConfig.qmd?.command ?? "qmd",
|
||||
env: process.env,
|
||||
cwd: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
if (!qmdCheck.available) {
|
||||
const workspaceProbeFailed =
|
||||
|
||||
@@ -49,11 +49,11 @@ function createSkill(overrides: Partial<SkillStatusEntry>): SkillStatusEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function createReport(skills: SkillStatusEntry[]): SkillStatusReport {
|
||||
function createReport(skills: SkillStatusEntry[], agentId = "main"): SkillStatusReport {
|
||||
return {
|
||||
workspaceDir: "/tmp/ws",
|
||||
managedSkillsDir: "/tmp/managed",
|
||||
agentId: "main",
|
||||
agentId,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
@@ -189,6 +189,43 @@ describe("doctor skills", () => {
|
||||
expect(calls.some((call) => call[1] === "GitHub CLI")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not offer a global disable when another agent can use the skill", async () => {
|
||||
mocks.note.mockClear();
|
||||
mocks.buildWorkspaceSkillStatus.mockClear();
|
||||
const healthy = createSkill({ name: "shared", skillKey: "shared" });
|
||||
const missing = createSkill({
|
||||
name: "shared",
|
||||
skillKey: "shared",
|
||||
eligible: false,
|
||||
missing: { bins: ["shared-bin"], anyBins: [], env: [], config: [], os: [] },
|
||||
});
|
||||
mocks.buildWorkspaceSkillStatus.mockImplementation((_workspaceDir, { agentId }) =>
|
||||
createReport(agentId === "secondary" ? [missing] : [healthy], agentId),
|
||||
);
|
||||
const confirmAutoFix = vi.fn(async () => true);
|
||||
const prompter = { ...createPrompter(), confirmAutoFix };
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: "/tmp/main" },
|
||||
{ id: "secondary", workspace: "/tmp/secondary" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const next = await maybeRepairSkillReadiness({ cfg, prompter });
|
||||
|
||||
expect(next).toBe(cfg);
|
||||
expect(confirmAutoFix).not.toHaveBeenCalled();
|
||||
expect(mocks.buildWorkspaceSkillStatus).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
String(mocks.note.mock.calls.find(([, title]) => title === "Skills")?.[0] ?? ""),
|
||||
).toContain('Agent "secondary"');
|
||||
expect(
|
||||
String(mocks.note.mock.calls.find(([, title]) => title === "Skills")?.[0] ?? ""),
|
||||
).not.toContain("doctor --fix");
|
||||
});
|
||||
|
||||
it("disables unavailable skills through skills.entries without dropping existing config", () => {
|
||||
const config: OpenClawConfig = {
|
||||
skills: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Doctor checks and repair prompts for unavailable configured skills. */
|
||||
import { existsSync } from "node:fs";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SkillStatusEntry } from "../skills/discovery/status.js";
|
||||
@@ -56,7 +56,10 @@ function describeGhConfigDirHintFromDiscovery(
|
||||
}
|
||||
|
||||
/** Formats doctor note lines for skills that are allowed but unavailable. */
|
||||
function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
|
||||
function formatUnavailableSkillDoctorLines(
|
||||
skills: SkillStatusEntry[],
|
||||
includeDisableHint = true,
|
||||
): string[] {
|
||||
const count = skills.length;
|
||||
const lines = [
|
||||
`${count} allowed skill${count === 1 ? " is" : "s are"} not usable in this environment (missing binaries, env vars, or config).`,
|
||||
@@ -65,43 +68,90 @@ function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[]
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.join(", ")}`,
|
||||
];
|
||||
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
|
||||
if (includeDisableHint) {
|
||||
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
|
||||
}
|
||||
lines.push(
|
||||
`Inspect details: ${formatCliCommand("openclaw skills check --agent <id>")} or ${formatCliCommand("openclaw skills info <name> --agent <id>")}`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Checks default-agent skill readiness and optionally disables unavailable skills in config. */
|
||||
function collectFleetUnavailableSkills(
|
||||
reports: Array<{ unavailable: SkillStatusEntry[]; skills: SkillStatusEntry[] }>,
|
||||
): SkillStatusEntry[] {
|
||||
const healthyKeys = new Set(
|
||||
reports.flatMap(({ skills }) =>
|
||||
skills
|
||||
.filter((skill) => skill.eligible && !skill.blockedByAgentFilter)
|
||||
.map((skill) => skill.skillKey),
|
||||
),
|
||||
);
|
||||
const candidates = new Map<string, SkillStatusEntry>();
|
||||
for (const skill of reports.flatMap(({ unavailable }) => unavailable)) {
|
||||
if (!healthyKeys.has(skill.skillKey)) {
|
||||
candidates.set(skill.skillKey, skill);
|
||||
}
|
||||
}
|
||||
return [...candidates.values()];
|
||||
}
|
||||
|
||||
/** Checks every agent's skill readiness and disables only fleet-wide unavailable skills. */
|
||||
export async function maybeRepairSkillReadiness(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: DoctorPrompter;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const agentId = resolveDefaultAgentId(params.cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: params.cfg,
|
||||
const agentIds = listAgentIds(params.cfg);
|
||||
const scopes = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
|
||||
}));
|
||||
const reports = scopes.map(({ agentId, workspaceDir }) => {
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
return { agentId, report, unavailable: collectUnavailableAgentSkills(report) };
|
||||
});
|
||||
const githubHint = describeGhConfigDirHint(report.skills);
|
||||
if (githubHint.length > 0) {
|
||||
note(githubHint.join("\n"), "GitHub CLI");
|
||||
const fleetUnavailable = collectFleetUnavailableSkills(
|
||||
reports.map(({ report, unavailable: unavailableForAgent }) => ({
|
||||
skills: report.skills,
|
||||
unavailable: unavailableForAgent,
|
||||
})),
|
||||
);
|
||||
const globallyUnavailableKeys = new Set(fleetUnavailable.map((skill) => skill.skillKey));
|
||||
for (const { agentId, report, unavailable: unavailableForAgent } of reports) {
|
||||
const prefix = agentIds.length > 1 ? `Agent "${agentId}":\n` : "";
|
||||
const githubHint = describeGhConfigDirHint(report.skills);
|
||||
if (githubHint.length > 0) {
|
||||
note(`${prefix}${githubHint.join("\n")}`, "GitHub CLI");
|
||||
}
|
||||
if (unavailableForAgent.length > 0) {
|
||||
const includesGlobalCandidate = unavailableForAgent.some((skill) =>
|
||||
globallyUnavailableKeys.has(skill.skillKey),
|
||||
);
|
||||
note(
|
||||
`${prefix}${formatUnavailableSkillDoctorLines(unavailableForAgent, includesGlobalCandidate).join("\n")}`,
|
||||
"Skills",
|
||||
);
|
||||
}
|
||||
}
|
||||
const unavailable = collectUnavailableAgentSkills(report);
|
||||
if (unavailable.length === 0) {
|
||||
if (fleetUnavailable.length === 0) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
note(formatUnavailableSkillDoctorLines(unavailable).join("\n"), "Skills");
|
||||
const shouldDisable = await params.prompter.confirmAutoFix({
|
||||
message: `Disable ${unavailable.length} unavailable skill${unavailable.length === 1 ? "" : "s"} in config?`,
|
||||
message:
|
||||
agentIds.length === 1
|
||||
? `Disable ${fleetUnavailable.length} unavailable skill${fleetUnavailable.length === 1 ? "" : "s"} in config?`
|
||||
: `Disable ${fleetUnavailable.length} skill${fleetUnavailable.length === 1 ? "" : "s"} unavailable to every configured agent?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!shouldDisable) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
const next = disableUnavailableSkillsInConfig(params.cfg, unavailable);
|
||||
note(unavailable.map((skill) => `- Disabled ${skill.name}`).join("\n"), "Doctor changes");
|
||||
const next = disableUnavailableSkillsInConfig(params.cfg, fleetUnavailable);
|
||||
note(fleetUnavailable.map((skill) => `- Disabled ${skill.name}`).join("\n"), "Doctor changes");
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./doctor-workspace-status.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listAgentIds: vi.fn<(_cfg: OpenClawConfig) => string[]>(() => ["default"]),
|
||||
resolveAgentWorkspaceDir: vi.fn(),
|
||||
resolveDefaultAgentId: vi.fn(),
|
||||
buildPluginRegistrySnapshotReport: vi.fn(),
|
||||
@@ -24,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
listAgentIds: (cfg: OpenClawConfig) => mocks.listAgentIds(cfg),
|
||||
resolveAgentWorkspaceDir: (...args: unknown[]) => mocks.resolveAgentWorkspaceDir(...args),
|
||||
resolveDefaultAgentId: (...args: unknown[]) => mocks.resolveDefaultAgentId(...args),
|
||||
}));
|
||||
@@ -55,6 +57,7 @@ async function runNoteWorkspaceStatusForTest(
|
||||
) {
|
||||
const cfg: OpenClawConfig = opts?.cfg ?? {};
|
||||
mocks.resolveDefaultAgentId.mockReturnValue("default");
|
||||
mocks.listAgentIds.mockReturnValue(["default"]);
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
|
||||
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
|
||||
workspaceDir: "/workspace",
|
||||
@@ -490,4 +493,32 @@ describe("noteWorkspaceStatus", () => {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("labels workspace diagnostics for the affected secondary agent", () => {
|
||||
mocks.buildPluginRegistrySnapshotReport.mockClear();
|
||||
mocks.listAgentIds.mockReturnValue(["default", "secondary"]);
|
||||
mocks.resolveAgentWorkspaceDir.mockImplementation((_cfg, agentId) => `/${agentId}`);
|
||||
mocks.buildPluginRegistrySnapshotReport.mockImplementation(({ workspaceDir }) => ({
|
||||
workspaceDir,
|
||||
...createPluginLoadResult({
|
||||
plugins: [],
|
||||
diagnostics:
|
||||
workspaceDir === "/secondary"
|
||||
? [{ level: "error", pluginId: "broken", message: "load failed" }]
|
||||
: [],
|
||||
}),
|
||||
}));
|
||||
mocks.buildPluginCompatibilityWarnings.mockReturnValue([]);
|
||||
mocks.listTaskFlowRecords.mockReturnValue([]);
|
||||
|
||||
const findings = collectWorkspaceStatusHealthFindings({});
|
||||
|
||||
expect(mocks.buildPluginRegistrySnapshotReport).toHaveBeenCalledTimes(2);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
message: 'Agent "secondary": load failed',
|
||||
path: "plugins.entries.broken",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Doctor status summary for workspace skills, plugins, and task-flow recovery hints. */
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HealthFinding } from "../flows/health-checks.js";
|
||||
@@ -107,11 +111,12 @@ function pluginCompatibilityWarningToHealthFinding(message: string): HealthFindi
|
||||
|
||||
function pluginDiagnosticToHealthFinding(
|
||||
diagnostic: ReturnType<typeof buildPluginRegistrySnapshotReport>["diagnostics"][number],
|
||||
message = diagnostic.message,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: WORKSPACE_STATUS_CHECK_ID,
|
||||
severity: diagnostic.level === "error" ? "error" : "warning",
|
||||
message: diagnostic.message,
|
||||
message,
|
||||
...(diagnostic.pluginId ? { path: `plugins.entries.${diagnostic.pluginId}` } : {}),
|
||||
...(diagnostic.pluginId ? { target: diagnostic.pluginId } : {}),
|
||||
...(diagnostic.source ? { source: diagnostic.source } : {}),
|
||||
@@ -138,21 +143,33 @@ export function collectWorkspaceStatusHealthFindings(
|
||||
cfg: OpenClawConfig,
|
||||
options: NoteWorkspaceStatusOptions = {},
|
||||
): HealthFinding[] {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const pluginRegistry = buildPluginRegistrySnapshotReport({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
});
|
||||
const compatibilityWarnings = buildPluginCompatibilityWarnings({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
report: pluginRegistry,
|
||||
});
|
||||
const agentIds = listAgentIds(cfg);
|
||||
const scopes = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
}));
|
||||
const workspaceFindings: HealthFinding[] = [];
|
||||
for (const { agentId, workspaceDir } of scopes) {
|
||||
const prefix = agentIds.length > 1 ? `Agent "${agentId}": ` : "";
|
||||
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
|
||||
const compatibilityWarnings = buildPluginCompatibilityWarnings({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
report: pluginRegistry,
|
||||
});
|
||||
for (const message of compatibilityWarnings) {
|
||||
workspaceFindings.push(pluginCompatibilityWarningToHealthFinding(`${prefix}${message}`));
|
||||
}
|
||||
for (const diagnostic of pluginRegistry.diagnostics) {
|
||||
workspaceFindings.push(
|
||||
pluginDiagnosticToHealthFinding(diagnostic, `${prefix}${diagnostic.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...pluginVersionDriftToHealthFindings(options.pluginVersionDrift),
|
||||
...compatibilityWarnings.map(pluginCompatibilityWarningToHealthFinding),
|
||||
...pluginRegistry.diagnostics.map(pluginDiagnosticToHealthFinding),
|
||||
...workspaceFindings,
|
||||
...collectTaskFlowRecoveryFindings().map(taskFlowRecoveryToHealthFinding),
|
||||
];
|
||||
}
|
||||
@@ -186,44 +203,55 @@ function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) {
|
||||
|
||||
/** Emits plugin and TaskFlow recovery problem notes for doctor. */
|
||||
export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceStatusOptions = {}) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const pluginRegistry = buildPluginRegistrySnapshotReport({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
});
|
||||
const errored = pluginRegistry.plugins
|
||||
.filter((plugin) => plugin.status === "error")
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
if (errored.length > 0) {
|
||||
const lines = [
|
||||
`Errors: ${errored.length}`,
|
||||
`- ${errored
|
||||
.slice(0, 10)
|
||||
.map((plugin) => plugin.id)
|
||||
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
|
||||
];
|
||||
note(lines.join("\n"), "Plugins");
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const agentIds = listAgentIds(cfg);
|
||||
const scopes = agentIds.map((agentId) => ({
|
||||
agentId,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
}));
|
||||
for (const { agentId, workspaceDir } of scopes) {
|
||||
const prefix = agentIds.length > 1 ? `Agent "${agentId}":\n` : "";
|
||||
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
|
||||
const errored = pluginRegistry.plugins
|
||||
.filter((plugin) => plugin.status === "error")
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
if (errored.length > 0) {
|
||||
const lines = [
|
||||
`${prefix}Errors: ${errored.length}`,
|
||||
`- ${errored
|
||||
.slice(0, 10)
|
||||
.map((plugin) => plugin.id)
|
||||
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
|
||||
];
|
||||
note(lines.join("\n"), "Plugins");
|
||||
}
|
||||
const compatibilityWarnings = buildPluginCompatibilityWarnings({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
report: pluginRegistry,
|
||||
});
|
||||
if (compatibilityWarnings.length > 0) {
|
||||
note(
|
||||
`${prefix}${compatibilityWarnings.map((line) => `- ${line}`).join("\n")}`,
|
||||
"Plugin compatibility",
|
||||
);
|
||||
}
|
||||
if (pluginRegistry.diagnostics.length > 0) {
|
||||
const lines = pluginRegistry.diagnostics.map((diag) => {
|
||||
const level = diag.level.toUpperCase();
|
||||
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
|
||||
const source = diag.source ? ` (${diag.source})` : "";
|
||||
return `- ${level}${plugin}: ${diag.message}${source}`;
|
||||
});
|
||||
note(`${prefix}${lines.join("\n")}`, "Plugin diagnostics");
|
||||
}
|
||||
}
|
||||
notePluginVersionDrift(options.pluginVersionDrift);
|
||||
const compatibilityWarnings = buildPluginCompatibilityWarnings({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
report: pluginRegistry,
|
||||
});
|
||||
if (compatibilityWarnings.length > 0) {
|
||||
note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility");
|
||||
}
|
||||
if (pluginRegistry.diagnostics.length > 0) {
|
||||
const lines = pluginRegistry.diagnostics.map((diag) => {
|
||||
const prefix = diag.level.toUpperCase();
|
||||
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
|
||||
const source = diag.source ? ` (${diag.source})` : "";
|
||||
return `- ${prefix}${plugin}: ${diag.message}${source}`;
|
||||
});
|
||||
note(lines.join("\n"), "Plugin diagnostics");
|
||||
}
|
||||
|
||||
noteFlowRecoveryHints();
|
||||
|
||||
return { workspaceDir };
|
||||
return {
|
||||
workspaceDir:
|
||||
scopes.find((scope) => scope.agentId === defaultAgentId)?.workspaceDir ??
|
||||
scopes[0]?.workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,19 +314,35 @@ export async function migrateLegacyRootMemoryFile(
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceMemoryDoctorScope = {
|
||||
agentId: string;
|
||||
workspaceDir: string;
|
||||
labelAgent: boolean;
|
||||
};
|
||||
|
||||
/** Emits workspace root-memory health warnings. */
|
||||
export async function noteWorkspaceMemoryHealth(cfg: OpenClawConfig): Promise<void> {
|
||||
export async function noteWorkspaceMemoryHealth(
|
||||
cfg: OpenClawConfig,
|
||||
scope?: WorkspaceMemoryDoctorScope,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const agentId = scope?.agentId ?? resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = scope?.workspaceDir ?? resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const rootMemoryWarning = formatRootMemoryFilesWarning(
|
||||
await detectRootMemoryFiles(workspaceDir),
|
||||
);
|
||||
if (rootMemoryWarning) {
|
||||
note(rootMemoryWarning, "Workspace memory");
|
||||
note(
|
||||
`${scope?.labelAgent ? `Agent "${agentId}":\n` : ""}${rootMemoryWarning}`,
|
||||
"Workspace memory",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
note(`Workspace memory audit could not be completed: ${formatErrorMessage(err)}`, "Doctor");
|
||||
const prefix = scope?.labelAgent ? `Agent "${scope.agentId}": ` : "";
|
||||
note(
|
||||
`${prefix}Workspace memory audit could not be completed: ${formatErrorMessage(err)}`,
|
||||
"Doctor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,16 +350,19 @@ export async function noteWorkspaceMemoryHealth(cfg: OpenClawConfig): Promise<vo
|
||||
export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: DoctorPrompter;
|
||||
scope?: WorkspaceMemoryDoctorScope;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const agentId = resolveDefaultAgentId(params.cfg);
|
||||
const configuredWorkspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const agentId = params.scope?.agentId ?? resolveDefaultAgentId(params.cfg);
|
||||
const configuredWorkspaceDir =
|
||||
params.scope?.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const prefix = params.scope?.labelAgent ? `Agent "${agentId}": ` : "";
|
||||
const rootMemoryFiles = await detectRootMemoryFiles(configuredWorkspaceDir);
|
||||
if (!rootMemoryFiles.canonicalExists || !rootMemoryFiles.legacyExists) {
|
||||
return;
|
||||
}
|
||||
const approvedLegacyMigration = await params.prompter.confirmRuntimeRepair({
|
||||
message: `Merge legacy root ${LEGACY_ROOT_MEMORY_FILENAME} into canonical ${CANONICAL_ROOT_MEMORY_FILENAME} and remove the shadowed file?`,
|
||||
message: `${prefix}Merge legacy root ${LEGACY_ROOT_MEMORY_FILENAME} into canonical ${CANONICAL_ROOT_MEMORY_FILENAME} and remove the shadowed file?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!approvedLegacyMigration) {
|
||||
@@ -353,7 +372,7 @@ export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
if (migration.readLimitExceeded) {
|
||||
note(
|
||||
[
|
||||
"Workspace memory root repair skipped (a file exceeded the safe read limit):",
|
||||
`${prefix}Workspace memory root repair skipped (a file exceeded the safe read limit):`,
|
||||
`- canonical: ${migration.canonicalPath}`,
|
||||
`- legacy: ${migration.legacyPath}`,
|
||||
migration.archivedLegacyPath
|
||||
@@ -369,7 +388,7 @@ export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
if (migration.readError) {
|
||||
note(
|
||||
[
|
||||
"Workspace memory root repair skipped (a file could not be read):",
|
||||
`${prefix}Workspace memory root repair skipped (a file could not be read):`,
|
||||
`- canonical: ${migration.canonicalPath}`,
|
||||
`- legacy: ${migration.legacyPath}`,
|
||||
migration.archivedLegacyPath
|
||||
@@ -385,7 +404,7 @@ export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
if (migration.archiveError) {
|
||||
note(
|
||||
[
|
||||
"Workspace memory root repair skipped (legacy memory could not be archived atomically):",
|
||||
`${prefix}Workspace memory root repair skipped (legacy memory could not be archived atomically):`,
|
||||
`- canonical: ${migration.canonicalPath}`,
|
||||
`- legacy: ${migration.legacyPath}`,
|
||||
].join("\n"),
|
||||
@@ -397,7 +416,7 @@ export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
"Workspace memory root merged:",
|
||||
`${prefix}Workspace memory root merged:`,
|
||||
`- canonical: ${migration.canonicalPath}`,
|
||||
migration.archivedLegacyPath ? `- backup: ${migration.archivedLegacyPath}` : null,
|
||||
migration.mergedLegacy ? `- merged legacy content from: ${migration.legacyPath}` : null,
|
||||
@@ -407,6 +426,10 @@ export async function maybeRepairWorkspaceMemoryHealth(params: {
|
||||
].filter(Boolean);
|
||||
note(lines.join("\n"), "Doctor changes");
|
||||
} catch (err) {
|
||||
note(`Workspace memory repair could not be completed: ${formatErrorMessage(err)}`, "Doctor");
|
||||
const prefix = params.scope?.labelAgent ? `Agent "${params.scope.agentId}": ` : "";
|
||||
note(
|
||||
`${prefix}Workspace memory repair could not be completed: ${formatErrorMessage(err)}`,
|
||||
"Doctor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user