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, }, }; },