Skip to content

Commit b2c507c

Browse files
authored
Doctor: expose tool result cap findings (#97500)
* doctor: expose tool result cap findings * doctor: include inherited tool cap findings * doctor: align tool cap agent paths
1 parent 8c7ac9b commit b2c507c

4 files changed

Lines changed: 366 additions & 47 deletions

src/flows/doctor-health-contributions.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,18 @@ const mocks = vi.hoisted(() => ({
6161
maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(),
6262
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
6363
resolveDefaultAgentId: vi.fn(() => "default"),
64+
resolveAgentContextLimits: vi.fn(
65+
(cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) =>
66+
cfg.agents?.defaults?.contextLimits ?? {},
67+
),
6468
note: vi.fn(),
6569
loadModelCatalog: vi.fn(async () => []),
70+
findModelCatalogEntry: vi.fn(() => ({ contextTokens: 200_000 })),
6671
getModelRefStatus: vi.fn(() => ({ allowed: true, inCatalog: true, key: "openai/gpt-5.5" })),
6772
resolveConfiguredModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })),
73+
resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })),
6874
resolveHooksGmailModel: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })),
75+
modelKey: vi.fn((provider: string, model: string) => `${provider}/${model}`),
6976
replaceConfigFile: vi.fn().mockResolvedValue(undefined),
7077
readConfigFileSnapshot: vi.fn().mockResolvedValue({
7178
exists: true,
@@ -243,6 +250,7 @@ vi.mock("../commands/doctor-browser.js", () => ({
243250
vi.mock("../agents/agent-scope.js", () => ({
244251
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
245252
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
253+
resolveAgentContextLimits: mocks.resolveAgentContextLimits,
246254
}));
247255

248256
vi.mock("../../packages/terminal-core/src/note.js", () => ({
@@ -251,12 +259,15 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
251259

252260
vi.mock("../agents/model-catalog.js", () => ({
253261
loadModelCatalog: mocks.loadModelCatalog,
262+
findModelCatalogEntry: mocks.findModelCatalogEntry,
254263
}));
255264

256265
vi.mock("../agents/model-selection.js", () => ({
257266
getModelRefStatus: mocks.getModelRefStatus,
258267
resolveConfiguredModelRef: mocks.resolveConfiguredModelRef,
268+
resolveDefaultModelForAgent: mocks.resolveDefaultModelForAgent,
259269
resolveHooksGmailModel: mocks.resolveHooksGmailModel,
270+
modelKey: mocks.modelKey,
260271
}));
261272

262273
vi.mock("../version.js", async () => ({
@@ -441,9 +452,16 @@ describe("doctor health contributions", () => {
441452
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace");
442453
mocks.resolveDefaultAgentId.mockReset();
443454
mocks.resolveDefaultAgentId.mockReturnValue("default");
455+
mocks.resolveAgentContextLimits.mockReset();
456+
mocks.resolveAgentContextLimits.mockImplementation(
457+
(cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) =>
458+
cfg.agents?.defaults?.contextLimits ?? {},
459+
);
444460
mocks.note.mockReset();
445461
mocks.loadModelCatalog.mockReset();
446462
mocks.loadModelCatalog.mockResolvedValue([]);
463+
mocks.findModelCatalogEntry.mockReset();
464+
mocks.findModelCatalogEntry.mockReturnValue({ contextTokens: 200_000 });
447465
mocks.getModelRefStatus.mockReset();
448466
mocks.getModelRefStatus.mockReturnValue({
449467
allowed: true,
@@ -452,8 +470,12 @@ describe("doctor health contributions", () => {
452470
});
453471
mocks.resolveConfiguredModelRef.mockReset();
454472
mocks.resolveConfiguredModelRef.mockReturnValue({ provider: "openai", model: "gpt-5.5" });
473+
mocks.resolveDefaultModelForAgent.mockReset();
474+
mocks.resolveDefaultModelForAgent.mockReturnValue({ provider: "openai", model: "gpt-5.5" });
455475
mocks.resolveHooksGmailModel.mockReset();
456476
mocks.resolveHooksGmailModel.mockReturnValue({ provider: "openai", model: "gpt-5.5" });
477+
mocks.modelKey.mockReset();
478+
mocks.modelKey.mockImplementation((provider: string, model: string) => `${provider}/${model}`);
457479
mocks.readConfigFileSnapshot.mockReset();
458480
mocks.readConfigFileSnapshot.mockResolvedValue({
459481
exists: true,
@@ -1191,9 +1213,103 @@ describe("doctor health contributions", () => {
11911213
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
11921214
expect(contributionIds).toContain("core/doctor/device-pairing");
11931215
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
1216+
expect(contributionIds).toContain("core/doctor/tool-result-cap");
11941217
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
11951218
});
11961219

1220+
it("keeps tool result cap opt-in for default lint selection", async () => {
1221+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1222+
const toolResultCapCheck = contributionChecks.find(
1223+
(check) => check.id === "core/doctor/tool-result-cap",
1224+
);
1225+
expect(toolResultCapCheck).toMatchObject({ defaultEnabled: false });
1226+
expect(toolResultCapCheck).toBeDefined();
1227+
1228+
const ctx = {
1229+
cfg: {
1230+
agents: {
1231+
defaults: { contextLimits: { toolResultMaxChars: 16_000 } },
1232+
},
1233+
},
1234+
mode: "lint",
1235+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1236+
} as const;
1237+
const checks = [toolResultCapCheck!];
1238+
1239+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
1240+
checksRun: 0,
1241+
checksSkipped: 1,
1242+
});
1243+
await expect(
1244+
runDoctorLintChecks(ctx, { checks, includeAllChecks: true }),
1245+
).resolves.toMatchObject({
1246+
checksRun: 1,
1247+
checksSkipped: 0,
1248+
findings: [
1249+
expect.objectContaining({
1250+
checkId: "core/doctor/tool-result-cap",
1251+
path: "agents.defaults.contextLimits.toolResultMaxChars",
1252+
}),
1253+
],
1254+
});
1255+
await expect(
1256+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/tool-result-cap"] }),
1257+
).resolves.toMatchObject({
1258+
checksRun: 1,
1259+
checksSkipped: 0,
1260+
});
1261+
});
1262+
1263+
it("reports agent findings for inherited default tool result caps", async () => {
1264+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1265+
const toolResultCapCheck = contributionChecks.find(
1266+
(check) => check.id === "core/doctor/tool-result-cap",
1267+
);
1268+
expect(toolResultCapCheck).toBeDefined();
1269+
1270+
mocks.resolveAgentContextLimits.mockImplementation(
1271+
(cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) =>
1272+
cfg.agents?.defaults?.contextLimits ?? {},
1273+
);
1274+
mocks.resolveDefaultModelForAgent.mockImplementation((...args: unknown[]) => {
1275+
const params = args[0] as { agentId?: string };
1276+
return params.agentId === "writer"
1277+
? { provider: "openai", model: "gpt-5.5" }
1278+
: { provider: "local", model: "tiny" };
1279+
});
1280+
mocks.findModelCatalogEntry.mockImplementation((...args: unknown[]) => {
1281+
const params = args[1] as { modelId?: string };
1282+
return params.modelId === "gpt-5.5" ? { contextTokens: 200_000 } : { contextTokens: 8_000 };
1283+
});
1284+
1285+
const ctx = {
1286+
cfg: {
1287+
agents: {
1288+
defaults: { contextLimits: { toolResultMaxChars: 16_000 } },
1289+
list: [{ id: "writer" }],
1290+
},
1291+
},
1292+
mode: "lint" as const,
1293+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1294+
};
1295+
1296+
await expect(
1297+
runDoctorLintChecks(ctx, {
1298+
checks: [toolResultCapCheck!],
1299+
onlyIds: ["core/doctor/tool-result-cap"],
1300+
}),
1301+
).resolves.toMatchObject({
1302+
checksRun: 1,
1303+
findings: expect.arrayContaining([
1304+
expect.objectContaining({
1305+
checkId: "core/doctor/tool-result-cap",
1306+
path: "agents.defaults.contextLimits.toolResultMaxChars",
1307+
target: "agents.list.writer",
1308+
}),
1309+
]),
1310+
});
1311+
});
1312+
11971313
it("keeps state integrity opt-in for default lint selection", async () => {
11981314
const contributionChecks = await resolveDoctorContributionHealthChecks();
11991315
const stateIntegrityCheck = contributionChecks.find(

src/flows/doctor-health-contributions.ts

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -786,74 +786,161 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise<void>
786786
}
787787
}
788788

789-
async function runToolResultCapHealth(ctx: DoctorHealthFlowContext): Promise<void> {
789+
type ToolResultCapTarget = {
790+
agentId?: string;
791+
configuredCap?: number;
792+
path?: string;
793+
scopeLabel: string;
794+
target?: string;
795+
};
796+
797+
async function collectToolResultCapFindings(
798+
cfg: OpenClawConfig,
799+
): Promise<readonly HealthFinding[]> {
790800
const { resolveAgentContextLimits } = await loadAgentScopeModule();
791801
const { normalizeAgentId } = await import("../routing/session-key.js");
792-
const targets: Array<{
793-
agentId?: string;
794-
configuredCap?: number;
795-
scopeLabel: string;
796-
}> = [];
797-
const defaultsConfiguredCap = ctx.cfg.agents?.defaults?.contextLimits?.toolResultMaxChars;
798-
if (ctx.options.deep === true || defaultsConfiguredCap !== undefined) {
802+
const targets: ToolResultCapTarget[] = [];
803+
const defaultsConfiguredCap = cfg.agents?.defaults?.contextLimits?.toolResultMaxChars;
804+
if (defaultsConfiguredCap !== undefined) {
799805
targets.push({
800806
configuredCap: defaultsConfiguredCap,
807+
path: "agents.defaults.contextLimits.toolResultMaxChars",
801808
scopeLabel: "defaults",
809+
target: "agents.defaults",
802810
});
803811
}
804-
for (const entry of ctx.cfg.agents?.list ?? []) {
812+
for (const entry of cfg.agents?.list ?? []) {
805813
const normalizedAgentId = normalizeAgentId(entry.id);
806814
if (
807815
!normalizedAgentId ||
808-
(ctx.options.deep !== true &&
809-
defaultsConfiguredCap === undefined &&
810-
entry.contextLimits?.toolResultMaxChars === undefined)
816+
(defaultsConfiguredCap === undefined && entry.contextLimits?.toolResultMaxChars === undefined)
811817
) {
812818
continue;
813819
}
814820
targets.push({
815821
agentId: normalizedAgentId,
816-
configuredCap: resolveAgentContextLimits(ctx.cfg, normalizedAgentId)?.toolResultMaxChars,
822+
configuredCap: resolveAgentContextLimits(cfg, normalizedAgentId)?.toolResultMaxChars,
823+
path:
824+
entry.contextLimits?.toolResultMaxChars === undefined
825+
? "agents.defaults.contextLimits.toolResultMaxChars"
826+
: `agents.list.${normalizedAgentId}.contextLimits.toolResultMaxChars`,
817827
scopeLabel: `agent "${normalizedAgentId}"`,
828+
target: `agents.list.${normalizedAgentId}`,
818829
});
819830
}
820831
if (targets.length === 0) {
821-
return;
832+
return [];
822833
}
823834

835+
const { collectToolResultCapDoctorIssues, toolResultCapDoctorIssueToHealthFinding } =
836+
await import("./doctor-tool-result-cap-advice.js");
837+
838+
return collectToolResultCapTargetAdvice({
839+
cfg,
840+
readOnlyCatalog: true,
841+
targets,
842+
}).then((entries) =>
843+
entries.flatMap((entry) =>
844+
collectToolResultCapDoctorIssues(entry).map(toolResultCapDoctorIssueToHealthFinding),
845+
),
846+
);
847+
}
848+
849+
async function collectToolResultCapTargetAdvice(params: {
850+
cfg: OpenClawConfig;
851+
readOnlyCatalog?: boolean;
852+
targets: readonly ToolResultCapTarget[];
853+
}): Promise<
854+
Array<{
855+
contextWindowTokens: number;
856+
modelKey: string;
857+
configuredCap?: number;
858+
deep?: boolean;
859+
path?: string;
860+
scopeLabel?: string;
861+
target?: string;
862+
}>
863+
> {
824864
const { DEFAULT_CONTEXT_TOKENS } = await loadAgentDefaultsModule();
825865
const { loadModelCatalog, findModelCatalogEntry } = await loadModelCatalogModule();
826866
const { resolveContextWindowInfo } = await import("../agents/context-window-guard.js");
827867
const { resolveDefaultModelForAgent, modelKey } = await loadModelSelectionModule();
828-
const { buildToolResultCapDoctorAdvice } = await import("./doctor-tool-result-cap-advice.js");
829-
const { note } = await loadNoteModule();
868+
const catalog = await loadModelCatalog({
869+
config: params.cfg,
870+
...(params.readOnlyCatalog ? { readOnly: true } : {}),
871+
});
830872

831-
const catalog = await loadModelCatalog({ config: ctx.cfg });
832-
const lines = targets.flatMap((target) => {
873+
return params.targets.map((target) => {
833874
const modelRef = resolveDefaultModelForAgent({
834-
cfg: ctx.cfg,
875+
cfg: params.cfg,
835876
agentId: target.agentId,
836877
});
837878
const entry = findModelCatalogEntry(catalog, {
838879
provider: modelRef.provider,
839880
modelId: modelRef.model,
840881
});
841882
const contextWindow = resolveContextWindowInfo({
842-
cfg: ctx.cfg,
883+
cfg: params.cfg,
843884
provider: modelRef.provider,
844885
modelId: modelRef.model,
845886
modelContextTokens: entry?.contextTokens,
846887
modelContextWindow: entry?.contextWindow,
847888
defaultTokens: DEFAULT_CONTEXT_TOKENS,
848889
});
849-
return buildToolResultCapDoctorAdvice({
890+
return {
850891
contextWindowTokens: contextWindow.tokens,
851892
modelKey: modelKey(modelRef.provider, modelRef.model),
852893
configuredCap: target.configuredCap,
853-
deep: ctx.options.deep === true,
894+
path: target.path,
854895
scopeLabel: target.scopeLabel,
896+
target: target.target,
897+
};
898+
});
899+
}
900+
901+
async function runToolResultCapHealth(ctx: DoctorHealthFlowContext): Promise<void> {
902+
const { resolveAgentContextLimits } = await loadAgentScopeModule();
903+
const { normalizeAgentId } = await import("../routing/session-key.js");
904+
const targets: ToolResultCapTarget[] = [];
905+
const defaultsConfiguredCap = ctx.cfg.agents?.defaults?.contextLimits?.toolResultMaxChars;
906+
if (ctx.options.deep === true || defaultsConfiguredCap !== undefined) {
907+
targets.push({
908+
configuredCap: defaultsConfiguredCap,
909+
scopeLabel: "defaults",
910+
});
911+
}
912+
for (const entry of ctx.cfg.agents?.list ?? []) {
913+
const normalizedAgentId = normalizeAgentId(entry.id);
914+
if (
915+
!normalizedAgentId ||
916+
(ctx.options.deep !== true &&
917+
defaultsConfiguredCap === undefined &&
918+
entry.contextLimits?.toolResultMaxChars === undefined)
919+
) {
920+
continue;
921+
}
922+
targets.push({
923+
agentId: normalizedAgentId,
924+
configuredCap: resolveAgentContextLimits(ctx.cfg, normalizedAgentId)?.toolResultMaxChars,
925+
scopeLabel: `agent "${normalizedAgentId}"`,
855926
});
927+
}
928+
if (targets.length === 0) {
929+
return;
930+
}
931+
932+
const { buildToolResultCapDoctorAdvice } = await import("./doctor-tool-result-cap-advice.js");
933+
const { note } = await loadNoteModule();
934+
const entries = await collectToolResultCapTargetAdvice({
935+
cfg: ctx.cfg,
936+
targets,
856937
});
938+
const lines = entries.flatMap((entry) =>
939+
buildToolResultCapDoctorAdvice({
940+
...entry,
941+
deep: ctx.options.deep === true,
942+
}),
943+
);
857944
if (lines.length > 0) {
858945
note(lines.join("\n"), "Tool result cap");
859946
}
@@ -1679,6 +1766,13 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
16791766
createDoctorHealthContribution({
16801767
id: "doctor:tool-result-cap",
16811768
label: "Tool result cap",
1769+
healthChecks: {
1770+
id: "core/doctor/tool-result-cap",
1771+
description:
1772+
"Detect explicit toolResultMaxChars settings that fight model-window defaults.",
1773+
defaultEnabled: false,
1774+
detect: async (ctx) => collectToolResultCapFindings(ctx.cfg),
1775+
},
16821776
run: runToolResultCapHealth,
16831777
}),
16841778
createDoctorHealthContribution({

0 commit comments

Comments
 (0)