fix(doctor): check every configured agent (#111758)

This commit is contained in:
Peter Steinberger
2026-07-20 02:05:53 -07:00
committed by GitHub
parent 5d80b62d21
commit 922240568a
11 changed files with 845 additions and 399 deletions
+35 -1
View File
@@ -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);
});
});
+82 -64
View File
@@ -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",
);
}
}
}
+117 -5
View File
@@ -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", () => {
+225 -174
View File
@@ -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 =
+39 -2
View File
@@ -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: {
+67 -17
View File
@@ -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",
}),
]);
});
});
+78 -50
View File
@@ -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,
};
}
+36 -13
View File
@@ -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",
);
}
}