From a97fe41a9e5148ad448270c140c261fbd5d4e25f Mon Sep 17 00:00:00 2001 From: hcl Date: Sun, 26 Apr 2026 05:07:42 +0800 Subject: [PATCH] perf(cli): skip plugin load on agents list --json (#71739) (#71746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporter measured `agents list --json` at ~7-9s on a fast host (~11s in container) on 2026.4.23, while peer `--json` commands like `channels list`, `cron list --all`, and `sessions ... --all-agents` stay sub-second. Their cold-call dashboard endpoint dropped from 27s to ~2s after a local dist patch — they could even retire the 5-min cache TTL workaround they had shipped to dodge it. Root cause: `agents list` inherits `loadPlugins: 'always'` from the parent `agents` policy in command-catalog, then `agentsListCommand` calls `buildProviderStatusIndex(cfg)` unconditionally — both paths trigger the bundled-extension import waterfall (~60+ extension index.js modules). `channels list` already uses `loadPlugins: 'never'` and proves the shape is right; this PR matches that shape with the safer `text-only` variant so human invocations are unchanged. Two-line fix per reporter: 1. `src/cli/command-catalog.ts` — opt agents list into `text-only`, the same plugin-preload policy bucket that already exists. Plugin preload runs for human text output, skips for `--json`. 2. `src/commands/agents.commands.list.ts` — skip `buildProviderStatusIndex` (and the per-summary provider enrichment loop) when `opts.json`. Provider info is only rendered in human text output via `formatSummary`, so dropping it from JSON has no observable effect on existing callers that consume `id`, `name`, `model`, `bindings`, `isDefault`, `identity*`, `workspace`, or `agentDir`. `routes` is config-derived and continues to be set in both modes. Tests: - new assertion in command-startup-policy.test.ts: `agents list` with jsonOutputMode:true now resolves to `loadPlugins: false` (was effectively `true` via the parent `agents` 'always' policy). - existing assertion that human (jsonOutputMode:false) still triggers plugin load is preserved verbatim. 6/6 tests pass. Lint clean. Out of scope: - `--bindings` flag opt-in for restoring providers in JSON output: worth adding later if any consumer needs it; reporter said dashboard consumers don't. - Broader plugin-discovery cache work (#67040, #71690) which addresses the same family of cold-start cost. --- src/cli/command-catalog.ts | 6 ++++++ src/cli/command-startup-policy.test.ts | 9 +++++++++ src/commands/agents.commands.list.ts | 27 +++++++++++++++++--------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index d3b01987d9b..ede0e796e7b 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -73,6 +73,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, { commandPath: ["agents", "list"], + // JSON callers (dashboards, monitoring scripts, IDE plugins) poll this + // command and don't need the plugin-derived `providers` enrichment that + // is only used in human text output. text-only skips the bundled-plugin + // import waterfall in `--json` mode, mirroring what `channels list` + // already does. Human (non-JSON) invocations still load plugins. (#71739) + policy: { loadPlugins: "text-only" }, route: { id: "agents-list" }, }, { diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 051c0bbbdcf..6bf36f6f5a3 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -80,6 +80,15 @@ describe("command-startup-policy", () => { jsonOutputMode: false, }), ).toBe(true); + // text-only opts agents list out of plugin preload in --json mode so + // dashboards/scripts that poll this command don't pay the bundled-plugin + // import waterfall when they only consume config-derived fields. (#71739) + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents", "list"], + jsonOutputMode: true, + }), + ).toBe(false); }); it("matches banner suppression policy", () => { diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts index b18b4a4b8f4..720def19074 100644 --- a/src/commands/agents.commands.list.ts +++ b/src/commands/agents.commands.list.ts @@ -99,7 +99,14 @@ export async function agentsListCommand( } } - const providerStatus = await buildProviderStatusIndex(cfg); + // `buildProviderStatusIndex` triggers on-demand plugin loads and is only + // used for human text output (`summary.providers` is rendered in the text + // formatter). JSON callers (dashboards, monitors, IDE plugins) poll this + // command and don't need provider enrichment, so skip the plugin load when + // emitting JSON — combined with `loadPlugins: "text-only"` in the catalog + // entry, this drops `agents list --json` cold time from ~9s to sub-second. + // (#71739) + const providerStatus = opts.json ? null : await buildProviderStatusIndex(cfg); for (const summary of summaries) { const bindings = bindingMap.get(summary.id) ?? []; @@ -110,14 +117,16 @@ export async function agentsListCommand( summary.routes = ["default (no explicit rules)"]; } - const providerLines = listProvidersForAgent({ - summaryIsDefault: summary.isDefault, - cfg, - bindings, - providerStatus, - }); - if (providerLines.length > 0) { - summary.providers = providerLines; + if (providerStatus) { + const providerLines = listProvidersForAgent({ + summaryIsDefault: summary.isDefault, + cfg, + bindings, + providerStatus, + }); + if (providerLines.length > 0) { + summary.providers = providerLines; + } } }