From c4b18ab3c9ae8c586bbfe02a279b13b2817cc3b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:58:50 -0700 Subject: [PATCH] Status: split lightweight gateway agent list --- src/commands/status-all/agents.ts | 4 +- src/commands/status.agent-local.ts | 4 +- src/commands/status.summary.test.ts | 11 +++- src/commands/status.summary.ts | 9 +-- src/commands/status.test.ts | 17 ++++-- src/gateway/agent-list.ts | 88 +++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 src/gateway/agent-list.ts diff --git a/src/commands/status-all/agents.ts b/src/commands/status-all/agents.ts index caf1ae03ed2..e8d7c485fe5 100644 --- a/src/commands/status-all/agents.ts +++ b/src/commands/status-all/agents.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import { listAgentsForGateway } from "../../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../../gateway/agent-list.js"; async function fileExists(p: string): Promise { try { @@ -15,7 +15,7 @@ async function fileExists(p: string): Promise { } export async function getAgentLocalStatuses(cfg: OpenClawConfig) { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const agents = await Promise.all( diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index 5c57036eb97..ce17f9ab94f 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -4,7 +4,7 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { listAgentsForGateway } from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; export type AgentLocalStatus = { id: string; @@ -36,7 +36,7 @@ async function fileExists(p: string): Promise { export async function getAgentLocalStatuses( cfg: OpenClawConfig = loadConfig(), ): Promise { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const statuses: AgentLocalStatus[] = []; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 2045c380e1b..12ce55844c3 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -32,12 +32,15 @@ vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/sessions.json"), })); -vi.mock("../gateway/session-utils.js", () => ({ - classifySessionKey: vi.fn(() => "direct"), - listAgentsForGateway: vi.fn(() => ({ +vi.mock("../gateway/agent-list.js", () => ({ + listGatewayAgentsBasic: vi.fn(() => ({ defaultId: "main", agents: [{ id: "main" }], })), +})); + +vi.mock("../gateway/session-utils.js", () => ({ + classifySessionKey: vi.fn(() => "direct"), resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-5.2", @@ -61,6 +64,8 @@ vi.mock("../infra/system-events.js", () => ({ })); vi.mock("../routing/session-key.js", () => ({ + normalizeAgentId: vi.fn((value: string) => value), + normalizeMainKey: vi.fn((value?: string) => value ?? "main"), parseAgentSessionKey: vi.fn(() => null), })); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 6de3b282648..3d151c64772 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -11,11 +11,8 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { - classifySessionKey, - listAgentsForGateway, - resolveSessionModelRef, -} from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -107,7 +104,7 @@ export async function getStatusSummary( resolveLinkChannelContext(cfg), ) : null; - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); return { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index f3dfd37064a..3e68d55ced2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -168,7 +168,7 @@ const mocks = vi.hoisted(() => ({ configSnapshot: null, }), callGateway: vi.fn().mockResolvedValue({}), - listAgentsForGateway: vi.fn().mockReturnValue({ + listGatewayAgentsBasic: vi.fn().mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -299,11 +299,18 @@ vi.mock("../gateway/call.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, callGateway: mocks.callGateway }; }); +vi.mock("../gateway/agent-list.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listGatewayAgentsBasic: mocks.listGatewayAgentsBasic, + }; +}); + vi.mock("../gateway/session-utils.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listAgentsForGateway: mocks.listAgentsForGateway, }; }); vi.mock("../infra/openclaw-root.js", () => ({ @@ -608,11 +615,11 @@ describe("statusCommand", () => { }); it("includes sessions across agents in JSON output", async () => { - const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); + const originalAgents = mocks.listGatewayAgentsBasic.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); - mocks.listAgentsForGateway.mockReturnValue({ + mocks.listGatewayAgentsBasic.mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -651,7 +658,7 @@ describe("statusCommand", () => { ).toBe(true); if (originalAgents) { - mocks.listAgentsForGateway.mockImplementation(originalAgents); + mocks.listGatewayAgentsBasic.mockImplementation(originalAgents); } if (originalResolveStorePath) { mocks.resolveStorePath.mockImplementation(originalResolveStorePath); diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts new file mode 100644 index 00000000000..d14cdf0c534 --- /dev/null +++ b/src/gateway/agent-list.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { SessionScope } from "../config/sessions.js"; +import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js"; + +export type GatewayAgentListRow = { + id: string; + name?: string; +}; + +function listExistingAgentIdsFromDisk(): string[] { + const root = resolveStateDir(); + const agentsDir = path.join(root, "agents"); + try { + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => normalizeAgentId(entry.name)) + .filter(Boolean); + } catch { + return []; + } +} + +function listConfiguredAgentIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + ids.add(defaultId); + + for (const entry of cfg.agents?.list ?? []) { + if (entry?.id) { + ids.add(normalizeAgentId(entry.id)); + } + } + + for (const id of listExistingAgentIdsFromDisk()) { + ids.add(id); + } + + const sorted = Array.from(ids).filter(Boolean); + sorted.sort((a, b) => a.localeCompare(b)); + return sorted.includes(defaultId) + ? [defaultId, ...sorted.filter((id) => id !== defaultId)] + : sorted; +} + +export function listGatewayAgentsBasic(cfg: OpenClawConfig): { + defaultId: string; + mainKey: string; + scope: SessionScope; + agents: GatewayAgentListRow[]; +} { + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const mainKey = normalizeMainKey(cfg.session?.mainKey); + const scope = cfg.session?.scope ?? "per-sender"; + const configuredById = new Map(); + for (const entry of cfg.agents?.list ?? []) { + if (!entry?.id) { + continue; + } + configuredById.set(normalizeAgentId(entry.id), { + name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, + }); + } + const explicitIds = new Set( + (cfg.agents?.list ?? []) + .map((entry) => (entry?.id ? normalizeAgentId(entry.id) : "")) + .filter(Boolean), + ); + const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null; + let agentIds = listConfiguredAgentIds(cfg).filter((id) => + allowedIds ? allowedIds.has(id) : true, + ); + if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) { + agentIds = [...agentIds, mainKey]; + } + const agents = agentIds.map((id) => { + const meta = configuredById.get(id); + return { + id, + name: meta?.name, + }; + }); + return { defaultId, mainKey, scope, agents }; +}