From 922240568a73a4e1bf5ed125a3858cdc4470cded Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 02:05:53 -0700 Subject: [PATCH] fix(doctor): check every configured agent (#111758) --- src/commands/doctor-bootstrap-size.test.ts | 36 +- src/commands/doctor-bootstrap-size.ts | 146 ++++--- .../doctor-heartbeat-template-repair.test.ts | 40 ++ .../doctor-heartbeat-template-repair.ts | 168 ++++---- src/commands/doctor-memory-search.test.ts | 122 +++++- src/commands/doctor-memory-search.ts | 399 ++++++++++-------- src/commands/doctor-skills.test.ts | 41 +- src/commands/doctor-skills.ts | 84 +++- src/commands/doctor-workspace-status.test.ts | 31 ++ src/commands/doctor-workspace-status.ts | 128 +++--- src/commands/doctor-workspace.ts | 49 ++- 11 files changed, 845 insertions(+), 399 deletions(-) diff --git a/src/commands/doctor-bootstrap-size.test.ts b/src/commands/doctor-bootstrap-size.test.ts index 5a36b8a4e53..4eafd21e5b1 100644 --- a/src/commands/doctor-bootstrap-size.test.ts +++ b/src/commands/doctor-bootstrap-size.test.ts @@ -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); + }); }); diff --git a/src/commands/doctor-bootstrap-size.ts b/src/commands/doctor-bootstrap-size.ts index 1e55e6c02b0..3f157521669 100644 --- a/src/commands/doctor-bootstrap-size.ts +++ b/src/commands/doctor-bootstrap-size.ts @@ -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 | 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; } diff --git a/src/commands/doctor-heartbeat-template-repair.test.ts b/src/commands/doctor-heartbeat-template-repair.test.ts index 1e35b6cac84..4d14fd5123c 100644 --- a/src/commands/doctor-heartbeat-template-repair.test.ts +++ b/src/commands/doctor-heartbeat-template-repair.test.ts @@ -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", + ); + }); }); diff --git a/src/commands/doctor-heartbeat-template-repair.ts b/src/commands/doctor-heartbeat-template-repair.ts index 024a6f08710..cc3f952b715 100644 --- a/src/commands/doctor-heartbeat-template-repair.ts +++ b/src/commands/doctor-heartbeat-template-repair.ts @@ -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 { } function heartbeatTemplateAnalysisToHealthFinding( + agentId: string, + labelAgent: boolean, heartbeatPath: string, analysis: Exclude, ): 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; }, ): Promise { - 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 { - 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", + ); + } } } diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 8444a0a56c3..1fedb101562 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -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>(); + 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", () => { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 45522923622..8a5a4e98e5d 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { + 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 = diff --git a/src/commands/doctor-skills.test.ts b/src/commands/doctor-skills.test.ts index 3982e5d595b..de5e5192423 100644 --- a/src/commands/doctor-skills.test.ts +++ b/src/commands/doctor-skills.test.ts @@ -49,11 +49,11 @@ function createSkill(overrides: Partial): 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: { diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts index 97cce0aaa84..f8871947fd9 100644 --- a/src/commands/doctor-skills.ts +++ b/src/commands/doctor-skills.ts @@ -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 ")} or ${formatCliCommand("openclaw skills info --agent ")}`, ); 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(); + 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 { - 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; } diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 571b313f9bb..9633404a062 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -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", + }), + ]); + }); }); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 149e8391313..584f7f44eba 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -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["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, + }; } diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index 87e56f134f9..0e68d2d5385 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -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 { +export async function noteWorkspaceMemoryHealth( + cfg: OpenClawConfig, + scope?: WorkspaceMemoryDoctorScope, +): Promise { 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 { 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", + ); } }