diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 2d824359311..63c5806ebae 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`): You can override the store path via `session.store` and `{agentId}` templating. +Gateway and ACP session discovery also scans disk-backed agent stores under the +default `agents/` root and under templated `session.store` roots. Discovered +stores must stay inside that resolved agent root and use a regular +`sessions.json` file. Symlinks and out-of-root paths are ignored. + ## WebChat behavior WebChat attaches to the **selected agent** and defaults to the agent’s main diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 4ed5ace54ee..b8c1ebfac6f 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -24,6 +24,12 @@ Scope selection: - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP +session discovery are broader: they also include disk-only stores found under +the default `agents/` root or a templated `session.store` root. Those +discovered stores must resolve to regular `sessions.json` files inside the +agent root; symlinks and out-of-root paths are skipped. + JSON examples: `openclaw sessions --all-agents --json`: diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 193deb6304f..8b2d9fc467f 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const loadSessionStoreMock = vi.fn(); const updateSessionStoreMock = vi.fn(); const callGatewayMock = vi.fn(); +const loadCombinedSessionStoreForGatewayMock = vi.fn(); const createMockConfig = () => ({ session: { mainKey: "main", scope: "per-sender" }, @@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +vi.mock("../gateway/session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: (cfg: unknown) => + loadCombinedSessionStoreForGatewayMock(cfg), + }; +}); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -95,7 +105,12 @@ function resetSessionStore(store: Record) { loadSessionStoreMock.mockClear(); updateSessionStoreMock.mockClear(); callGatewayMock.mockClear(); + loadCombinedSessionStoreForGatewayMock.mockClear(); loadSessionStoreMock.mockReturnValue(store); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store, + }); callGatewayMock.mockResolvedValue({}); mockConfig = createMockConfig(); } @@ -161,6 +176,30 @@ describe("session_status tool", () => { expect(details.sessionKey).toBe("agent:main:main"); }); + it("resolves duplicate sessionId inputs deterministically", async () => { + resetSessionStore({ + "agent:main:main": { + sessionId: "current", + updatedAt: 10, + }, + "agent:main:other": { + sessionId: "run-dup", + updatedAt: 999, + }, + "agent:main:acp:run-dup": { + sessionId: "run-dup", + updatedAt: 100, + }, + }); + + const tool = getSessionStatusTool(); + + const result = await tool.execute("call-dup", { sessionKey: "run-dup" }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:acp:run-dup"); + }); + it("uses non-standard session keys without sessionId resolution", async () => { resetSessionStore({ "temp:slug-generator": { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index aa9e0cac17b..132b470fd2f 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -23,6 +23,7 @@ import { resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js"; import { resolveAgentDir } from "../agent-scope.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { resolveModelAuthLabel } from "../model-auth-label.js"; @@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: { return null; } const { store } = loadCombinedSessionStoreForGateway(params.cfg); - const match = Object.entries(store).find(([key, entry]) => { - if (entry?.sessionId !== trimmed) { - return false; - } - if (!params.agentId) { - return true; - } - return resolveAgentIdFromSessionKey(key) === params.agentId; - }); - return match?.[0] ?? null; + const matches = Object.entries(store).filter( + (entry): entry is [string, SessionEntry] => + entry[1]?.sessionId === trimmed && + (!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId), + ); + return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null; } async function resolveModelOverride(params: { diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 949938520df..0a676f98ddf 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -60,6 +60,7 @@ function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boo function resolveValidatedDiscoveredStorePathSync(params: { sessionsDir: string; agentsRoot: string; + realAgentsRoot?: string; }): string | undefined { const storePath = path.join(params.sessionsDir, "sessions.json"); try { @@ -68,7 +69,7 @@ function resolveValidatedDiscoveredStorePathSync(params: { return undefined; } const realStorePath = fsSync.realpathSync(storePath); - const realAgentsRoot = fsSync.realpathSync(params.agentsRoot); + const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot); return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; } catch (err) { if (shouldSkipDiscoveryError(err)) { @@ -81,6 +82,7 @@ function resolveValidatedDiscoveredStorePathSync(params: { async function resolveValidatedDiscoveredStorePath(params: { sessionsDir: string; agentsRoot: string; + realAgentsRoot?: string; }): Promise { const storePath = path.join(params.sessionsDir, "sessions.json"); try { @@ -88,10 +90,8 @@ async function resolveValidatedDiscoveredStorePath(params: { if (stat.isSymbolicLink() || !stat.isFile()) { return undefined; } - const [realStorePath, realAgentsRoot] = await Promise.all([ - fs.realpath(storePath), - fs.realpath(params.agentsRoot), - ]); + const realStorePath = await fs.realpath(storePath); + const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot)); return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; } catch (err) { if (shouldSkipDiscoveryError(err)) { @@ -146,23 +146,50 @@ export function resolveAllAgentSessionStoreTargetsSync( ): SessionStoreTarget[] { const env = params.env ?? process.env; const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + const realAgentsRoots = new Map(); + const getRealAgentsRoot = (agentsRoot: string): string | undefined => { + const cached = realAgentsRoots.get(agentsRoot); + if (cached !== undefined) { + return cached; + } + try { + const realAgentsRoot = fsSync.realpathSync(agentsRoot); + realAgentsRoots.set(agentsRoot, realAgentsRoot); + return realAgentsRoot; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return undefined; + } + throw err; + } + }; const validatedConfiguredTargets = configuredTargets.flatMap((target) => { const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath); if (!agentsRoot) { return [target]; } + const realAgentsRoot = getRealAgentsRoot(agentsRoot); + if (!realAgentsRoot) { + return []; + } const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ sessionsDir: path.dirname(target.storePath), agentsRoot, + realAgentsRoot, }); return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : []; }); const discoveredTargets = agentsRoots.flatMap((agentsDir) => { try { + const realAgentsRoot = getRealAgentsRoot(agentsDir); + if (!realAgentsRoot) { + return []; + } return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => { const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ sessionsDir, agentsRoot: agentsDir, + realAgentsRoot, }); const target = validatedStorePath ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) @@ -185,6 +212,23 @@ export async function resolveAllAgentSessionStoreTargets( ): Promise { const env = params.env ?? process.env; const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + const realAgentsRoots = new Map(); + const getRealAgentsRoot = async (agentsRoot: string): Promise => { + const cached = realAgentsRoots.get(agentsRoot); + if (cached !== undefined) { + return cached; + } + try { + const realAgentsRoot = await fs.realpath(agentsRoot); + realAgentsRoots.set(agentsRoot, realAgentsRoot); + return realAgentsRoot; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return undefined; + } + throw err; + } + }; const validatedConfiguredTargets = ( await Promise.all( configuredTargets.map(async (target) => { @@ -192,9 +236,14 @@ export async function resolveAllAgentSessionStoreTargets( if (!agentsRoot) { return target; } + const realAgentsRoot = await getRealAgentsRoot(agentsRoot); + if (!realAgentsRoot) { + return undefined; + } const validatedStorePath = await resolveValidatedDiscoveredStorePath({ sessionsDir: path.dirname(target.storePath), agentsRoot, + realAgentsRoot, }); return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined; }), @@ -205,6 +254,10 @@ export async function resolveAllAgentSessionStoreTargets( await Promise.all( agentsRoots.map(async (agentsDir) => { try { + const realAgentsRoot = await getRealAgentsRoot(agentsDir); + if (!realAgentsRoot) { + return []; + } const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir); return ( await Promise.all( @@ -212,6 +265,7 @@ export async function resolveAllAgentSessionStoreTargets( const validatedStorePath = await resolveValidatedDiscoveredStorePath({ sessionsDir, agentsRoot: agentsDir, + realAgentsRoot, }); return validatedStorePath ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index 2ef954eeb93..858a37edf13 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; const RUN_LOOKUP_CACHE_LIMIT = 256; @@ -33,41 +34,6 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v }); } -function resolvePreferredRunStoreKey( - matches: Array<[string, SessionEntry]>, - runId: string, -): string | undefined { - if (matches.length === 0) { - return undefined; - } - if (matches.length === 1) { - return matches[0][0]; - } - - const loweredRunId = runId.trim().toLowerCase(); - const structuralMatches = matches.filter(([storeKey]) => { - const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase(); - return ( - storeKey.toLowerCase().endsWith(`:${loweredRunId}`) || - requestKey === loweredRunId || - requestKey?.endsWith(`:${loweredRunId}`) === true - ); - }); - if (structuralMatches.length === 1) { - return structuralMatches[0][0]; - } - - const sortedMatches = [...matches].toSorted( - (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), - ); - const [freshest, secondFreshest] = sortedMatches; - if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) { - return freshest?.[0]; - } - - return undefined; -} - export function resolveSessionKeyForRun(runId: string) { const cached = getAgentRunContext(runId)?.sessionKey; if (cached) { @@ -88,7 +54,7 @@ export function resolveSessionKeyForRun(runId: string) { const matches = Object.entries(store).filter( (entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId, ); - const storeKey = resolvePreferredRunStoreKey(matches, runId); + const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId); if (storeKey) { const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey; registerAgentRunContext(runId, { sessionKey }); diff --git a/src/sessions/session-id-resolution.ts b/src/sessions/session-id-resolution.ts new file mode 100644 index 00000000000..f0cde40c2e1 --- /dev/null +++ b/src/sessions/session-id-resolution.ts @@ -0,0 +1,37 @@ +import type { SessionEntry } from "../config/sessions.js"; +import { toAgentRequestSessionKey } from "../routing/session-key.js"; + +export function resolvePreferredSessionKeyForSessionIdMatches( + matches: Array<[string, SessionEntry]>, + sessionId: string, +): string | undefined { + if (matches.length === 0) { + return undefined; + } + if (matches.length === 1) { + return matches[0][0]; + } + + const loweredSessionId = sessionId.trim().toLowerCase(); + const structuralMatches = matches.filter(([storeKey]) => { + const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase(); + return ( + storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) || + requestKey === loweredSessionId || + requestKey?.endsWith(`:${loweredSessionId}`) === true + ); + }); + if (structuralMatches.length === 1) { + return structuralMatches[0][0]; + } + + const sortedMatches = [...matches].toSorted( + (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), + ); + const [freshest, secondFreshest] = sortedMatches; + if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) { + return freshest?.[0]; + } + + return undefined; +}