From 61e9961abb771cb12885e055d50d698d3886cbcd Mon Sep 17 00:00:00 2001 From: David <32288+nxmxbbd@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:25:47 +0800 Subject: [PATCH] fix(agents): expose session status route context Expose session status route context so agents can distinguish session origin, active live route, and persisted delivery route. Add maintainer fixup to keep active route metadata on the real live run key when policy and run keys differ. Thanks @nxmxbbd. Closes #84544 --- docs/concepts/session-tool.md | 13 ++ .../openclaw-tools.session-status.test.ts | 159 +++++++++++++++ src/agents/openclaw-tools.ts | 6 + src/agents/openclaw-tools.tts-config.test.ts | 40 +++- src/agents/tools/session-status-tool.ts | 185 +++++++++++++++++- 5 files changed, 394 insertions(+), 9 deletions(-) diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 7bccc1c3643..8cead232d40 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -120,6 +120,19 @@ sparse token/cache counters from the latest transcript usage entry, and the caller's current session; visible client labels such as `openclaw-tui` are not session keys. +When route metadata is available, `session_status` also includes a visible +`Route context` JSON block and matching structured `details` fields. These +fields disambiguate the session key from the route that is currently handling +the live run: + +- `origin` is where the session was created, or the provider inferred from a + deliverable session-key prefix when older state lacks stored origin metadata. +- `active` is the current live-run route. It is only reported for the live or + current session being handled now. +- `deliveryContext` is the persisted delivery route stored on the session, + which OpenClaw can reuse for later delivery even when the active surface + differs. + `sessions_yield` intentionally ends the current turn so the next message can be the follow-up event you are waiting for. Use it after spawning sub-agents when you want completion results to arrive as the next message instead of building diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index f165ffb9863..0b873167273 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -617,6 +617,165 @@ describe("session_status tool", () => { expect(details.statusText).toContain("OpenClaw"); }); + it("reports origin, active, and persisted delivery route metadata for semantic current", async () => { + const sessionKey = "agent:main:discord:channel:1489550370136129537"; + resetSessionStore({ + [sessionKey]: { + sessionId: "s-discord-origin-webchat-active", + updatedAt: 10, + origin: { provider: "discord", accountId: "bot-primary" }, + deliveryContext: { + channel: "discord", + to: "channel:1489550370136129537", + accountId: "bot-primary", + threadId: "thread-origin", + }, + }, + }); + + const tool = createSessionStatusTool({ + agentSessionKey: sessionKey, + runSessionKey: sessionKey, + activeDeliveryContext: { + channel: "webchat", + to: "control-ui-conversation", + accountId: "browser", + threadId: "webchat-thread", + }, + config: mockConfig as never, + }); + + const result = await tool.execute("call-current-route-context", { sessionKey: "current" }); + const details = result.details as { + ok?: boolean; + sessionKey?: string; + statusText?: string; + origin?: { provider?: string; accountId?: string }; + active?: { channel?: string; to?: string; accountId?: string; threadId?: string }; + deliveryContext?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string; + }; + }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe(sessionKey); + expect(details.origin).toEqual({ provider: "discord", accountId: "bot-primary" }); + expect(details.active).toEqual({ + channel: "webchat", + to: "control-ui-conversation", + accountId: "browser", + threadId: "webchat-thread", + }); + expect(details.deliveryContext).toEqual({ + channel: "discord", + to: "channel:1489550370136129537", + accountId: "bot-primary", + threadId: "thread-origin", + }); + const text = + result.content.find((item): item is { type: "text"; text: string } => item.type === "text") + ?.text ?? ""; + expect(text).toContain("Route context:"); + expect(text).toContain('"origin"'); + expect(text).toContain('"active"'); + expect(text).toContain('"deliveryContext"'); + expect(details.statusText).toContain('"active"'); + }); + + it("does not report an active route for explicit non-live session lookups", async () => { + const currentKey = "agent:main:main"; + const targetKey = "agent:main:discord:channel:1489550370136129537"; + resetSessionStore({ + [currentKey]: { + sessionId: "s-main", + updatedAt: 5, + }, + [targetKey]: { + sessionId: "s-target", + updatedAt: 10, + deliveryContext: { + channel: "discord", + to: "channel:1489550370136129537", + }, + }, + }); + mockConfig = { + ...mockConfig, + tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } }, + }; + + const tool = createSessionStatusTool({ + agentSessionKey: currentKey, + runSessionKey: currentKey, + activeDeliveryContext: { + channel: "webchat", + to: "control-ui-conversation", + }, + config: mockConfig as never, + }); + + const result = await tool.execute("call-explicit-non-live-route-context", { + sessionKey: targetKey, + }); + const details = result.details as { + origin?: { provider?: string }; + active?: { channel?: string }; + deliveryContext?: { channel?: string; to?: string }; + }; + expect(details.origin).toEqual({ provider: "discord" }); + expect(details.active).toBeUndefined(); + expect(details.deliveryContext).toEqual({ + channel: "discord", + to: "channel:1489550370136129537", + }); + }); + + it("does not report an active route for an explicit stale policy-key lookup", async () => { + const policyKey = "agent:main:telegram:default:direct:1234"; + const runKey = "agent:main:main"; + resetSessionStore({ + [policyKey]: { + sessionId: "s-policy", + updatedAt: 5, + deliveryContext: { + channel: "telegram", + to: "telegram:direct:1234", + }, + }, + [runKey]: { + sessionId: "s-run", + updatedAt: 10, + }, + }); + + const tool = createSessionStatusTool({ + agentSessionKey: policyKey, + runSessionKey: runKey, + activeDeliveryContext: { + channel: "webchat", + to: "control-ui-conversation", + }, + config: mockConfig as never, + }); + + const result = await tool.execute("call-explicit-stale-policy-key-route-context", { + sessionKey: policyKey, + }); + const details = result.details as { + sessionKey?: string; + active?: { channel?: string }; + deliveryContext?: { channel?: string; to?: string }; + }; + expect(details.sessionKey).toBe(policyKey); + expect(details.active).toBeUndefined(); + expect(details.deliveryContext).toEqual({ + channel: "telegram", + to: "telegram:direct:1234", + }); + }); + it("rejects explicit cross-session key under tree visibility even when it equals runSessionKey (#76708)", async () => { resetSessionStore({ "agent:main:telegram:default:direct:1234": { diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index e11d96fd7c5..439996ccb93 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -516,6 +516,12 @@ export function createOpenClawTools( sandboxed: options?.sandboxed, activeModelProvider: options?.modelProvider, activeModelId: options?.modelId, + activeDeliveryContext: { + channel: options?.agentChannel, + to: options?.currentChannelId ?? options?.agentTo, + accountId: options?.agentAccountId, + threadId: options?.currentThreadTs ?? options?.agentThreadId, + }, }), ...collectPresentOpenClawTools([webSearchTool, webFetchTool, imageTool, pdfTool]), ]; diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index 83f4a20417d..4bc0d38b23d 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => { return { stubTool, createCronToolOptions: vi.fn(), + createSessionStatusToolOptions: vi.fn(), createImageGenerateToolOptions: vi.fn(), createMusicGenerateToolOptions: vi.fn(), createVideoGenerateToolOptions: vi.fn(), @@ -83,7 +84,10 @@ vi.mock("./tools/pdf-tool.js", () => ({ })); vi.mock("./tools/session-status-tool.js", () => ({ - createSessionStatusTool: () => mocks.stubTool("session_status"), + createSessionStatusTool: (options: unknown) => { + mocks.createSessionStatusToolOptions(options); + return mocks.stubTool("session_status"); + }, })); vi.mock("./tools/sessions-history-tool.js", () => ({ @@ -351,6 +355,40 @@ describe("createOpenClawTools media generation session wiring", () => { }); }); +describe("createOpenClawTools session status route context wiring", () => { + beforeEach(() => { + mocks.createSessionStatusToolOptions.mockClear(); + }); + + it("passes the active live-run route into the session_status tool", () => { + createOpenClawTools({ + agentSessionKey: "agent:main:discord:channel:1489550370136129537", + runSessionKey: "agent:main:discord:channel:1489550370136129537", + agentChannel: "webchat", + agentAccountId: "browser", + agentTo: "channel:1489550370136129537", + agentThreadId: "origin-thread", + currentChannelId: "webchat:control-ui", + currentThreadTs: "webchat-thread-1", + disableMessageTool: true, + disablePluginTools: true, + }); + + expect(mocks.createSessionStatusToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:discord:channel:1489550370136129537", + runSessionKey: "agent:main:discord:channel:1489550370136129537", + activeDeliveryContext: { + channel: "webchat", + to: "webchat:control-ui", + accountId: "browser", + threadId: "webchat-thread-1", + }, + }), + ); + }); +}); + describe("createOpenClawTools cron context wiring", () => { beforeEach(() => { mocks.createCronToolOptions.mockClear(); diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 4465cd88f1d..5af0d23e3df 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -1,3 +1,4 @@ +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { Type } from "typebox"; import type { @@ -29,6 +30,15 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { BuildStatusTextParams } from "../../status/status-text.types.js"; import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js"; import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js"; +import { + deliveryContextFromSession, + normalizeDeliveryContext, + type DeliveryContext, +} from "../../utils/delivery-context.shared.js"; +import { + isDeliverableMessageChannel, + normalizeMessageChannel, +} from "../../utils/message-channel.js"; import { loadModelCatalog } from "../model-catalog.js"; import { buildModelAliasIndex, @@ -190,6 +200,140 @@ function listImplicitDefaultDirectFallbackKeys(params: { type ActiveStatusModelIdentity = { provider?: string; model: string }; +type SessionStatusOriginDetails = { + provider?: string; + accountId?: string; + threadId?: string | number; +}; + +type SessionStatusDeliveryContextDetails = { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; +}; + +type SessionStatusRouteDetails = { + origin?: SessionStatusOriginDetails; + active?: SessionStatusDeliveryContextDetails; + deliveryContext?: SessionStatusDeliveryContextDetails; +}; + +const INTERNAL_SESSION_KEY_ORIGIN_PREFIXES = new Set(["main", "cron", "subagent", "acp"]); + +function readRouteThreadId(value: unknown): string | number | undefined { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return undefined; +} + +function compactOriginDetails(params: { + provider?: string; + accountId?: string; + threadId?: string | number; +}): SessionStatusOriginDetails | undefined { + const threadId = readRouteThreadId(params.threadId); + const details: SessionStatusOriginDetails = { + ...(params.provider ? { provider: params.provider } : {}), + ...(params.accountId ? { accountId: params.accountId } : {}), + ...(threadId !== undefined ? { threadId } : {}), + }; + return Object.keys(details).length ? details : undefined; +} + +function compactDeliveryContextDetails(params: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; +}): SessionStatusDeliveryContextDetails | undefined { + const threadId = readRouteThreadId(params.threadId); + const details: SessionStatusDeliveryContextDetails = { + ...(params.channel ? { channel: params.channel } : {}), + ...(params.to ? { to: params.to } : {}), + ...(params.accountId ? { accountId: params.accountId } : {}), + ...(threadId !== undefined ? { threadId } : {}), + }; + return Object.keys(details).length ? details : undefined; +} + +function normalizeStatusDeliveryContext( + context?: DeliveryContext, +): SessionStatusDeliveryContextDetails | undefined { + return compactDeliveryContextDetails({ + channel: readStringValue(context?.channel), + to: readStringValue(context?.to), + accountId: readStringValue(context?.accountId), + threadId: context?.threadId, + }); +} + +function normalizeActiveDeliveryContext( + context?: DeliveryContext, +): SessionStatusDeliveryContextDetails | undefined { + if (!context) { + return undefined; + } + const normalized = normalizeDeliveryContext(context); + const rawChannel = readStringValue(normalized?.channel) ?? readStringValue(context.channel); + const channel = rawChannel ? (normalizeMessageChannel(rawChannel) ?? rawChannel) : undefined; + return compactDeliveryContextDetails({ + channel, + to: readStringValue(normalized?.to) ?? readStringValue(context.to), + accountId: readStringValue(normalized?.accountId) ?? readStringValue(context.accountId), + threadId: normalized?.threadId ?? context.threadId, + }); +} + +function inferOriginProviderFromSessionKey(sessionKey: string): string | undefined { + const parsed = parseAgentSessionKey(sessionKey); + const head = readStringValue(parsed?.rest.split(":")[0]); + if (!head || INTERNAL_SESSION_KEY_ORIGIN_PREFIXES.has(head.toLowerCase())) { + return undefined; + } + const channel = normalizeMessageChannel(head); + return channel && isDeliverableMessageChannel(channel) ? channel : undefined; +} + +function buildSessionStatusRouteDetails(params: { + entry: SessionEntry; + sessionKey: string; + activeDeliveryContext?: DeliveryContext; + isLiveRunSession?: boolean; +}): SessionStatusRouteDetails { + const origin = compactOriginDetails({ + provider: + readStringValue(params.entry.origin?.provider) ?? + inferOriginProviderFromSessionKey(params.sessionKey), + accountId: readStringValue(params.entry.origin?.accountId), + threadId: params.entry.origin?.threadId, + }); + const deliveryContext = normalizeStatusDeliveryContext(deliveryContextFromSession(params.entry)); + const active = params.isLiveRunSession + ? normalizeActiveDeliveryContext(params.activeDeliveryContext) + : undefined; + + return { + ...(origin ? { origin } : {}), + ...(active ? { active } : {}), + ...(deliveryContext ? { deliveryContext } : {}), + }; +} + +function formatSessionStatusRouteContext(details: SessionStatusRouteDetails): string | undefined { + if (Object.keys(details).length === 0) { + return undefined; + } + return `Route context: +\`\`\`json +${JSON.stringify(details, null, 2)} +\`\`\``; +} + function resolveActiveStatusModelIdentity(params: { activeModelId?: string; activeModelProvider?: string; @@ -348,6 +492,8 @@ export function createSessionStatusTool(opts?: { sandboxed?: boolean; activeModelProvider?: string; activeModelId?: string; + /** Active live-run route, kept separate from the persisted/origin delivery route. */ + activeDeliveryContext?: DeliveryContext; }): AnyAgentTool { return { label: "Session Status", @@ -673,17 +819,18 @@ export function createSessionStatusTool(opts?: { const activeModelId = opts?.activeModelId?.trim(); const activeModelProvider = opts?.activeModelProvider?.trim(); const isImplicitCurrentRequest = requestedKeyParam === undefined; + const liveSessionKeys = [ + opts?.runSessionKey, + storeScopedRequesterKey, + effectiveRequesterKey, + visibilityRequesterKey, + ]; const activeModelIdentity = resolveActiveStatusModelIdentity({ activeModelId, activeModelProvider, isImplicitCurrentRequest, isSemanticCurrentRequest, - liveSessionKeys: [ - opts?.runSessionKey, - storeScopedRequesterKey, - effectiveRequesterKey, - visibilityRequesterKey, - ], + liveSessionKeys, modelRaw, resolvedKey: resolved.key, }); @@ -767,6 +914,27 @@ export function createSessionStatusTool(opts?: { taskLine && !statusText.includes(taskLine) ? `${statusText}\n${taskLine}` : statusText; const resultOverrideProvider = statusSessionEntry.providerOverride?.trim(); const resultOverrideModel = statusSessionEntry.modelOverride?.trim(); + const liveSessionKeySet = new Set( + liveSessionKeys + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)), + ); + const activeRouteRunSessionKey = opts?.runSessionKey?.trim(); + const isLiveRouteSession = activeRouteRunSessionKey + ? resolved.key.trim() === activeRouteRunSessionKey + : liveSessionKeySet.has(resolved.key.trim()); + const routeDetails = buildSessionStatusRouteDetails({ + entry: statusSessionEntry, + sessionKey: resolved.key, + activeDeliveryContext: opts?.activeDeliveryContext, + isLiveRunSession: isLiveRouteSession, + }); + const routeContextText = formatSessionStatusRouteContext(routeDetails); + const visibleStatusText = routeContextText + ? `${fullStatusText} + +${routeContextText}` + : fullStatusText; const modelOverrideForResult = modelRaw === undefined ? undefined @@ -777,7 +945,7 @@ export function createSessionStatusTool(opts?: { : null; return { - content: [{ type: "text", text: fullStatusText }], + content: [{ type: "text", text: visibleStatusText }], details: { ok: true, sessionKey: resolved.key, @@ -791,7 +959,8 @@ export function createSessionStatusTool(opts?: { modelOverride: modelOverrideForResult, } : {}), - statusText: fullStatusText, + statusText: visibleStatusText, + ...routeDetails, }, }; },