From 895bf61ea0d37fdf7d1caa6e8e0c2d8ddd393f50 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 29 Jul 2026 07:39:20 +0900 Subject: [PATCH] feat(agents): surface watched-session awareness to the model (#114835) --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- docs/concepts/main-session.md | 3 +- docs/concepts/session-tool.md | 4 +- .../src/app-server/attempt-context.test.ts | 45 ++++++ .../codex/src/app-server/attempt-context.ts | 28 ++++ .../src/app-server/run-attempt-context.ts | 7 + scripts/plugin-sdk-surface-report.mjs | 6 +- .../prepared-compaction-runtime.ts | 14 ++ .../run/attempt-system-prompt-prepare.ts | 10 ++ .../embedded-agent-runner/system-prompt.ts | 3 + .../openclaw-tools.session-status.test.ts | 4 +- src/agents/system-prompt.test.ts | 57 +++++++ src/agents/system-prompt.ts | 20 ++- src/agents/tool-description-presets.ts | 36 ++++- src/agents/tools/session-status-tool.ts | 4 +- src/agents/tools/sessions-access.test.ts | 4 +- src/agents/tools/sessions-list-tool.ts | 3 +- src/agents/tools/sessions-spawn-tool.ts | 16 ++ src/agents/tools/sessions.test.ts | 2 +- src/agents/watched-sessions-prompt.test.ts | 153 ++++++++++++++++++ src/agents/watched-sessions-prompt.ts | 118 ++++++++++++++ src/plugin-sdk/agent-harness-runtime.ts | 24 +++ src/plugin-sdk/session-visibility.ts | 7 +- .../codex-dynamic-tools.discord-group.json | 2 +- .../codex-dynamic-tools.heartbeat-turn.json | 2 +- .../codex-dynamic-tools.telegram-direct.json | 2 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- 29 files changed, 569 insertions(+), 31 deletions(-) create mode 100644 src/agents/watched-sessions-prompt.test.ts create mode 100644 src/agents/watched-sessions-prompt.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b53a2b488eba..a3259266c157 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account 8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness -ccfc54a6dc389b38bb06c2dbff05b30f35ff4efd953887cac8066e1aae7db45d module/agent-harness-runtime +9285d1fee755e391a85f5ba892144684ee5b2c09d42410781324b4ddf9b36852 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload 2dcb4d62d90e5d71594f6b843c97534509a154784e378fb1c75bd86b5122b710 module/agent-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from diff --git a/docs/concepts/main-session.md b/docs/concepts/main-session.md index 893f3b31178d..3a12e6762174 100644 --- a/docs/concepts/main-session.md +++ b/docs/concepts/main-session.md @@ -37,7 +37,8 @@ world converges: Activity queues up as compact notices — coalesced per conversation, never one wake-up per message — and the agent sees them the next time it runs: on your next message or on a scheduled heartbeat. The agent can also read the - sessions it watches, so "what did I miss in the family group?" works. + sessions it watches — its system prompt names them — so "what did I miss in + the family group?" works. - **Background work.** Sub-agents and spawned sessions announce their results back to the session that started them, so work the agent kicked off from Home reports back to Home. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index d4861b194591..a20056558598 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -149,7 +149,9 @@ Session tools are scoped to limit what the agent can see: Default is `tree`. Sandboxed sessions are clamped to `tree` regardless of config. With the default `session.dmScope: "main"`, group activity makes watched -same-agent group sessions readable from the main session. +same-agent group sessions readable from the main session, and the main +session's system prompt lists those watched sessions so the agent knows it can +read them. ## Further reading diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts index c11f6178aafc..e2d99819676a 100644 --- a/extensions/codex/src/app-server/attempt-context.test.ts +++ b/extensions/codex/src/app-server/attempt-context.test.ts @@ -12,6 +12,8 @@ import { } from "openclaw/plugin-sdk/memory-host-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildCodexOpenClawPromptContext, + buildCodexWatchedSessionsContext, buildCodexWorkspaceBootstrapContext, buildCodexSystemPromptReport, readContextEngineThreadBootstrapProjection, @@ -249,4 +251,47 @@ describe("Codex app-server attempt context", () => { reason: "dynamic-tools-mismatch", }); }); + + it("stitches watched-session context into the per-turn OpenClaw prompt context", () => { + const attempt = { config: {} } as EmbeddedRunAttemptParams; + + expect( + buildCodexOpenClawPromptContext({ + params: attempt, + watchedSessionsContext: [ + "## Watched Sessions", + "- agent:main:telegram:group:beta — Family group", + ].join("\n"), + }), + ).toContain("## Watched Sessions"); + + // No ambient watches (and no state) must render nothing, not an empty section. + expect( + buildCodexWatchedSessionsContext({ + attempt, + dynamicTools: [ + { + type: "function", + name: "sessions_history", + description: "history", + inputSchema: {}, + }, + ], + sessionKey: "agent:codex-test:main", + }), + ).toBe(undefined); + + // Lightweight cron turns keep the runtime context byte-for-byte untouched. + expect( + buildCodexWatchedSessionsContext({ + attempt: { + config: {}, + bootstrapContextMode: "lightweight", + bootstrapContextRunKind: "cron", + } as EmbeddedRunAttemptParams, + dynamicTools: [], + sessionKey: "agent:codex-test:main", + }), + ).toBe(undefined); + }); }); diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 27e744cacca8..5a8da806b0d7 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -6,6 +6,7 @@ import { createHash } from "node:crypto"; import path from "node:path"; import { buildBootstrapContextForFiles, + buildWatchedSessionsHarnessContext, embeddedAgentLog, resolveBootstrapFilesForRun, type AgentMessage, @@ -503,6 +504,7 @@ function readNonEmptyString(value: unknown): string | undefined { export function buildCodexOpenClawPromptContext(params: { params: EmbeddedRunAttemptParams; workspacePromptContext?: string; + watchedSessionsContext?: string; }): string | undefined { if (!shouldInjectCodexOpenClawPromptContext(params.params)) { return undefined; @@ -511,6 +513,7 @@ export function buildCodexOpenClawPromptContext(params: { params.workspacePromptContext?.trim() ? ["## OpenClaw Workspace Context", "", params.workspacePromptContext.trim()].join("\n") : undefined, + params.watchedSessionsContext?.trim() || undefined, ].filter(isNonEmptyString); if (sections.length === 0) { return undefined; @@ -523,6 +526,31 @@ export function buildCodexOpenClawPromptContext(params: { ].join("\n"); } +/** + * Renders the watched-sessions block for the Codex per-turn runtime context. + * Codex builds its own instruction layers, so the embedded prompt's Watched + * Sessions section must be re-surfaced here or Codex-backed main sessions + * keep refusing cross-session questions (openclaw#114797). + */ +export function buildCodexWatchedSessionsContext(params: { + attempt: EmbeddedRunAttemptParams; + dynamicTools: readonly CodexDynamicToolSpec[]; + sessionKey?: string; + sandboxed?: boolean; +}): string | undefined { + if (!shouldInjectCodexOpenClawPromptContext(params.attempt)) { + return undefined; + } + return buildWatchedSessionsHarnessContext({ + config: params.attempt.config, + sessionKey: params.sessionKey, + sandboxed: params.sandboxed, + toolNames: flattenCodexDynamicToolFunctions(params.dynamicTools).map((tool) => + normalizeCodexDynamicToolName(tool.name), + ), + }); +} + function shouldInjectCodexOpenClawPromptContext(params: EmbeddedRunAttemptParams): boolean { // Lightweight cron runs are commonly exact commands. Keep the user input byte-for-byte // to avoid changing command intent while Codex keeps its native project-doc loader. diff --git a/extensions/codex/src/app-server/run-attempt-context.ts b/extensions/codex/src/app-server/run-attempt-context.ts index 761f5751f24d..7b4f83214ded 100644 --- a/extensions/codex/src/app-server/run-attempt-context.ts +++ b/extensions/codex/src/app-server/run-attempt-context.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { buildCodexOpenClawPromptContext, + buildCodexWatchedSessionsContext, buildCodexWorkspaceBootstrapContext, getCodexWorkspaceMemoryToolNames, readMirroredSessionHistoryMessages, @@ -147,6 +148,12 @@ export async function prepareCodexAttemptContext( const openClawPromptContext = buildCodexOpenClawPromptContext({ params: runtimeParams, workspacePromptContext: workspaceBootstrapContext.promptContext, + watchedSessionsContext: buildCodexWatchedSessionsContext({ + attempt: runtimeParams, + dynamicTools: toolBridge.availableSpecs, + sessionKey: contextSessionKey, + sandboxed: sandbox?.enabled === true, + }), }); const skillsCollaborationInstructions = renderCodexSkillsCollaborationInstructions({ attempt: runtimeParams, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index e75950721ef9..92e4729881de 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -196,7 +196,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +3: root-bounded walk iterator, options, and entry contract. // +5: pinned secret create/read functions and their options contract. // +1: canonical Gateway browser-origin acceptance for browser-facing plugin routes. - 4756, + // +1: watched-sessions prompt block for plugin-owned harness runtimes. + 4757, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -230,7 +231,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: root-bounded directory walk iterator. // +4: pinned secret create and synchronous/asynchronous reads. // +1: canonical Gateway browser-origin acceptance for browser-facing plugin routes. - 2877, + // +1: watched-sessions prompt block for plugin-owned harness runtimes. + 2878, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index 29749801e5d3..6a2b30c2f50a 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -60,6 +60,7 @@ import { filterRuntimeCompatibleTools, } from "../tool-schema-projection.js"; import { logRuntimeToolSchemaQuarantine } from "../tool-schema-quarantine.js"; +import { prepareWatchedSessionsPrompt } from "../watched-sessions-prompt.js"; import { resolveCompactionContextTokenBudget } from "./compaction-runtime-context.js"; import type { DirectCompactionPreparation } from "./direct-compaction-preparation.js"; import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; @@ -540,6 +541,18 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP agentSessionKey: runtimeInfo.sessionKey, sandboxed: sandboxInfo?.enabled === true, }); + // Compaction must build byte-identical prompt sections to live turns, or + // the compaction run misses the transcript's cached prompt prefix. The + // allowlist doubles as the capability set so a session-read tool reachable + // only through capability names gates the section the same way live turns do. + const preparedWatchedSessions = prepareWatchedSessionsPrompt({ + enabled: promptMode === "full", + config: params.config, + sessionKey: params.sessionKey, + sandboxed: sandboxInfo?.enabled === true, + toolNames: effectiveTools.map((tool) => tool.name), + capabilityToolNames: allowedToolNames, + }); const buildSystemPromptText = (defaultThinkLevel: ThinkLevel) => { const builtSystemPrompt = buildEmbeddedSystemPrompt({ config: params.config, @@ -575,6 +588,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP userTimeFormat, contextFiles, preparedMemoryPrompt, + preparedWatchedSessions, promptContribution, nativeCommandGuidanceLines, }); diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index 6c0b9ec1c865..d32fa41b051e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -34,6 +34,7 @@ import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import type { ToolSearchCatalogRef } from "../../tool-search.js"; import { buildToolSchemaDirectoryPrompt } from "../../tool-search.js"; +import { prepareWatchedSessionsPrompt } from "../../watched-sessions-prompt.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { buildEmbeddedSandboxInfo, resolveEmbeddedSandboxInfoExecPolicy } from "../sandbox-info.js"; import { buildEmbeddedSystemPrompt } from "../system-prompt.js"; @@ -248,6 +249,14 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { agentSessionKey: runtimeInfo.sessionKey, sandboxed: sandboxInfo?.enabled === true, }); + const preparedWatchedSessions = prepareWatchedSessionsPrompt({ + enabled: effectivePromptMode === "full", + config: attempt.config, + sessionKey: attempt.sessionKey, + sandboxed: sandboxInfo?.enabled === true, + toolNames: params.effectiveTools.map((tool) => tool.name), + capabilityToolNames: params.capabilityToolNames, + }); const attemptSystemPrompt = buildAttemptSystemPrompt({ isRawModelRun: params.isRawModelRun, @@ -302,6 +311,7 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { ), includeMemorySection, preparedMemoryPrompt, + preparedWatchedSessions, promptContribution, }, providerTransform: { diff --git a/src/agents/embedded-agent-runner/system-prompt.ts b/src/agents/embedded-agent-runner/system-prompt.ts index 7aa4218b884a..39d76d034c74 100644 --- a/src/agents/embedded-agent-runner/system-prompt.ts +++ b/src/agents/embedded-agent-runner/system-prompt.ts @@ -17,6 +17,7 @@ import type { AgentSession } from "../sessions/index.js"; import { buildConfiguredAgentSystemPrompt } from "../system-prompt-config.js"; import type { ProviderSystemPromptContribution } from "../system-prompt-contribution.js"; import type { PromptMode, SilentReplyPromptMode } from "../system-prompt.types.js"; +import type { PreparedWatchedSessionsPrompt } from "../watched-sessions-prompt.js"; import type { EmbeddedSandboxInfo } from "./types.js"; import type { ReasoningLevel, ThinkLevel } from "./utils.js"; @@ -93,6 +94,7 @@ export function buildEmbeddedSystemPrompt(params: { includeMemorySection?: boolean; memoryCitationsMode?: MemoryCitationsMode; preparedMemoryPrompt?: PreparedMemoryPromptSection; + preparedWatchedSessions?: PreparedWatchedSessionsPrompt; promptContribution?: ProviderSystemPromptContribution; }): string { return buildConfiguredAgentSystemPrompt({ @@ -139,6 +141,7 @@ export function buildEmbeddedSystemPrompt(params: { includeMemorySection: params.includeMemorySection, memoryCitationsMode: params.memoryCitationsMode, preparedMemoryPrompt: params.preparedMemoryPrompt, + preparedWatchedSessions: params.preparedWatchedSessions, promptContribution: params.promptContribution, }); } diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index e14b206b6b3b..8fafe6ed5d16 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -2231,7 +2231,7 @@ describe("session_status tool", () => { model: "default", }), ).rejects.toThrow( - "Session status visibility is restricted to the current session tree (tools.sessions.visibility=tree).", + "Session status visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).", ); expect(loadSessionStoreMock).not.toHaveBeenCalled(); @@ -2334,7 +2334,7 @@ describe("session_status tool", () => { model: "default", }), ).rejects.toThrow( - "Session status visibility is restricted to the current session tree (tools.sessions.visibility=tree).", + "Session status visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).", ); expect(updateSessionStoreMock).not.toHaveBeenCalled(); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 369354628851..cef51ae188b2 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1840,4 +1840,61 @@ describe("buildSubagentSystemPrompt", () => { } }); }); + +describe("watched sessions prompt surfaces", () => { + it("renders prepared watched sessions with titles, overflow, and recall guidance", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_list", "sessions_history", "sessions_search"], + preparedWatchedSessions: { + sessions: [ + { key: "agent:main:telegram:group:alpha", title: "Family group" }, + { key: "agent:main:telegram:group:beta" }, + ], + hiddenCount: 1, + readToolNames: ["sessions_history", "sessions_search"], + listToolAvailable: true, + }, + }); + + expect(prompt).toContain("## Watched Sessions"); + expect(prompt).toContain( + "Readable now (read-only) via sessions_history/sessions_search; rows appear in sessions_list.", + ); + expect(prompt).toContain("- agent:main:telegram:group:alpha — Family group"); + expect(prompt).toContain("- agent:main:telegram:group:beta"); + expect(prompt).toContain('(+1 more: sessions_list kinds=["group"].)'); + expect(prompt).toContain( + "Asked about another chat/group/session not in context: check `sessions_list`/`sessions_search` before claiming no access.", + ); + }); + + it("names only granted read tools and skips the sessions_list overflow hint without it", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_history"], + preparedWatchedSessions: { + sessions: [{ key: "agent:main:telegram:group:alpha" }], + hiddenCount: 2, + readToolNames: ["sessions_history"], + listToolAvailable: false, + }, + }); + + expect(prompt).toContain("Readable now (read-only) via sessions_history."); + expect(prompt).not.toContain("rows appear in sessions_list"); + expect(prompt).toContain("(+2 more.)"); + expect(prompt).not.toContain('sessions_list kinds=["group"]'); + }); + + it("omits the watched section and recall line without prepared data or session tools", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["read", "exec"], + }); + + expect(prompt).not.toContain("## Watched Sessions"); + expect(prompt).not.toContain("before claiming no access"); + }); +}); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 2179d404fe24..79bcd85801c1 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -62,6 +62,10 @@ import type { ProviderSystemPromptSectionId, } from "./system-prompt-contribution.js"; import type { PromptMode, SilentReplyPromptMode } from "./system-prompt.types.js"; +import { + buildWatchedSessionsPromptLines, + type PreparedWatchedSessionsPrompt, +} from "./watched-sessions-prompt.js"; /** * Controls which hardcoded sections are included in the system prompt. @@ -812,6 +816,8 @@ export function buildAgentSystemPrompt(params: { memoryCitationsMode?: MemoryCitationsMode; /** Immutable memory state prepared before synchronous prompt assembly. */ preparedMemoryPrompt?: PreparedMemoryPromptSection; + /** Watched same-agent group sessions prepared before synchronous prompt assembly. */ + preparedWatchedSessions?: PreparedWatchedSessionsPrompt; promptContribution?: ProviderSystemPromptContribution; }) { const acpEnabled = params.acpEnabled === true; @@ -850,8 +856,8 @@ export function buildAgentSystemPrompt(params: { agents_list: acpSpawnRuntimeEnabled ? "List allowed OpenClaw subagent ids; not ACP ids" : "List allowed subagent ids", - sessions_list: "List other sessions/subagents; filters/last", - sessions_history: "Read other session/subagent history", + sessions_list: "List visible sessions; filters/last", + sessions_history: "Read visible session/subagent history", sessions_search: "Search past sessions; use sessionKey with sessions_history", sessions_send: "Message other session/subagent", sessions_spawn: acpSpawnRuntimeEnabled @@ -1178,6 +1184,12 @@ export function buildAgentSystemPrompt(params: { : "Never loop-poll `subagents list`/`sessions_list`; status only on-demand/intervention/debug/request.", ] : []), + ...(renderOpenClawToolWorkflowHints && + (availableTools.has("sessions_search") || availableTools.has("sessions_list")) + ? [ + "Asked about another chat/group/session not in context: check `sessions_list`/`sessions_search` before claiming no access.", + ] + : []), "", ...buildProactiveSubagentOrchestrationSection({ enabled: proactiveSubagentOrchestration, @@ -1423,6 +1435,10 @@ export function buildAgentSystemPrompt(params: { lines.push(providerDynamicSuffix, ""); } + // Watched sessions change rarely but per-session; keep them below the cache + // boundary so the shared stable prefix stays byte-identical across sessions. + lines.push(...buildWatchedSessionsPromptLines(params.preparedWatchedSessions)); + lines.push(...buildHeartbeatSection({ isMinimal, heartbeatPrompt })); lines.push( diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index caec010eb2a2..48b80bcffcee 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -16,6 +16,33 @@ export const ASK_USER_TOOL_DISPLAY_SUMMARY = "Ask the user and wait for an answe export const SPAWN_TASK_TOOL_DISPLAY_SUMMARY = "Suggest follow-up work for operator approval."; export const DISMISS_TASK_TOOL_DISPLAY_SUMMARY = "Withdraw a pending task suggestion."; +// Mirrors plugin-sdk SessionToolsVisibility; kept local because importing that +// module here would close an agents<->plugin-sdk madge cycle. Call sites pass +// the policy union, so a new mode fails compilation at every consumer. +type SessionVisibilityScope = "self" | "tree" | "agent" | "all"; + +// Single source for model-facing session-visibility scope wording; every tool +// description or warning that explains visibility renders through this so the +// prose cannot drift from the session-visibility checker (openclaw#114797). +const SESSION_VISIBILITY_SCOPE_COPY = { + self: "current session only", + tree: "current session + own spawn subtree; reads also cover any watched same-agent group sessions", + agent: "all sessions of this agent", + all: "all sessions, cross-agent per tools.agentToAgent", +} satisfies Record; + +export function describeSessionVisibilityScope( + visibility: SessionVisibilityScope, + options?: { spawnRestricted?: boolean }, +): string { + // Sandboxed sessions under the "spawned" clamp list/read only spawned rows, + // so the tree watched-read clause would promise reads that context denies. + if (options?.spawnRestricted && visibility === "tree") { + return "current session + own spawn subtree (sandbox: spawned sessions only)"; + } + return SESSION_VISIBILITY_SCOPE_COPY[visibility]; +} + /** Describes the sessions_list tool for model-facing instructions. */ export function describeSessionsListTool(): string { return [ @@ -57,7 +84,14 @@ export function describeSessionsSpawnTool(options?: { acpAvailable?: boolean; threadAvailable?: boolean; swarmEnabled?: boolean; + sessionToolsVisibility?: SessionVisibilityScope; + spawnRestricted?: boolean; }): string { + // Callers that resolve the effective visibility get it rendered as fact; + // without it the copy must keep the "default" hedge instead of asserting tree. + const visibilityLine = options?.sessionToolsVisibility + ? `Session listing/addressing obeys \`tools.sessions.visibility\` (${options.sessionToolsVisibility}: ${describeSessionVisibilityScope(options.sessionToolsVisibility, { spawnRestricted: options.spawnRestricted })}).` + : `Session listing/addressing obeys \`tools.sessions.visibility\` (\`tree\` default: ${describeSessionVisibilityScope("tree")}).`; const runtimeDescription = options?.acpAvailable === false ? 'Spawn clean child; default `runtime="subagent"`.' @@ -76,7 +110,7 @@ export function describeSessionsSpawnTool(options?: { : '`mode="run"` one-shot background.', "`agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require.", '`visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.', - "Session listing/addressing obeys `tools.sessions.visibility` (`tree` default: current + own spawn subtree).", + visibilityLine, ...(options?.swarmEnabled ? [ "`collect=true` (swarm): parallel fan-out collector children; structured result per `outputSchema`; `groupId` groups a batch; await with agents_wait.", diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index e679f646f87d..b6357324b4d3 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -744,7 +744,9 @@ export function createSessionStatusTool(opts?: { visibilitySessionKey: requestedKeyInput, }); if (!visibleSession.ok) { - throw new Error("Session status visibility is restricted to the current session tree."); + // The resolver's copy already names the denying policy (including the + // watched-group carve-out); a local string here would drift from it. + throw new Error(visibleSession.error); } // If resolution points at another agent, enforce A2A policy before switching stores. ensureAgentAccess( diff --git a/src/agents/tools/sessions-access.test.ts b/src/agents/tools/sessions-access.test.ts index 467974bff978..dffec3336cc3 100644 --- a/src/agents/tools/sessions-access.test.ts +++ b/src/agents/tools/sessions-access.test.ts @@ -273,13 +273,13 @@ describe("createSessionVisibilityGuard", () => { allowed: false, status: "forbidden", error: - "Session history visibility is restricted to the current session tree (tools.sessions.visibility=tree).", + "Session history visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).", }); expect(guard.check({ key: explicitDirectSessionKey })).toEqual({ allowed: false, status: "forbidden", error: - "Session history visibility is restricted to the current session tree (tools.sessions.visibility=tree).", + "Session history visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).", }); expect(guard.check({ key: crossAgentSessionKey })).toEqual({ allowed: false, diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 2723907b262f..ddc0b5ae04a6 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -24,6 +24,7 @@ import { } from "../schema/typebox.js"; import { describeSessionsListTool, + describeSessionVisibilityScope, SESSIONS_LIST_TOOL_DISPLAY_SUMMARY, } from "../tool-description-presets.js"; import { stripToolMessages } from "./chat-history-text.js"; @@ -462,7 +463,7 @@ export function createSessionsListTool(opts?: { : { mode: visibility, restricted: true, - warning: `Session visibility is restricted (effective tools.sessions.visibility=${visibility}). Results may omit sessions outside the current scope. The count field reflects only sessions within the current scope.`, + warning: `Session visibility is restricted (effective tools.sessions.visibility=${visibility}: ${describeSessionVisibilityScope(visibility, { spawnRestricted: restrictToSpawned })}). Sessions outside that scope are omitted from results and count.`, }; return jsonResult({ diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 52add5ad9551..47eebc1133bc 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -48,6 +48,10 @@ import { readStringParam, ToolInputError, } from "./common.js"; +import { + resolveEffectiveSessionToolsVisibility, + resolveSandboxedSessionToolContext, +} from "./sessions-helpers.js"; import { maybeSpawnVisibleSession, type VisibleSessionsSpawnDeps, @@ -290,6 +294,16 @@ export function createSessionsSpawnTool( const requesterAgentId = opts?.requesterAgentIdOverride ?? parseAgentSessionKey(opts?.agentSessionKey)?.agentId; const swarmConfig = resolveSwarmConfig(opts?.config, requesterAgentId); + const visibilityCfg = opts?.config ?? getRuntimeConfig(); + const sessionToolsVisibility = resolveEffectiveSessionToolsVisibility({ + cfg: visibilityCfg, + sandboxed: opts?.sandboxed === true, + }); + const { restrictToSpawned } = resolveSandboxedSessionToolContext({ + cfg: visibilityCfg, + agentSessionKey: opts?.agentSessionKey, + sandboxed: opts?.sandboxed, + }); return { label: "Sessions", name: "sessions_spawn", @@ -300,6 +314,8 @@ export function createSessionsSpawnTool( acpAvailable, threadAvailable, swarmEnabled: swarmConfig.enabled, + sessionToolsVisibility, + spawnRestricted: restrictToSpawned, }), parameters: createSessionsSpawnToolSchema({ acpAvailable, diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index b6f24f3a77fa..bd7675f22ec4 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -655,7 +655,7 @@ describe("sessions_list gating", () => { mode: "tree", restricted: true, warning: - "Session visibility is restricted (effective tools.sessions.visibility=tree). Results may omit sessions outside the current scope. The count field reflects only sessions within the current scope.", + "Session visibility is restricted (effective tools.sessions.visibility=tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Sessions outside that scope are omitted from results and count.", }); }); diff --git a/src/agents/watched-sessions-prompt.test.ts b/src/agents/watched-sessions-prompt.test.ts new file mode 100644 index 000000000000..bcaae1f6a641 --- /dev/null +++ b/src/agents/watched-sessions-prompt.test.ts @@ -0,0 +1,153 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; +import { buildWatchedSessionsHarnessContext } from "../plugin-sdk/agent-harness-runtime.js"; +import { registerMainSessionGroupWatch } from "../sessions/session-state-events.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { prepareWatchedSessionsPrompt } from "./watched-sessions-prompt.js"; + +const tempDirs: string[] = []; +const mainSessionKey = "agent:main:main"; +const sessionReadTools = ["sessions_history", "sessions_search", "sessions_list"]; + +function stubStateDir() { + const stateDir = makeTempDir(tempDirs, "openclaw-watched-sessions-"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); +} + +function watchGroup(sessionKey: string) { + expect(registerMainSessionGroupWatch({ sessionKey, agentId: "main", dmScope: "main" })).toBe( + true, + ); +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + closeOpenClawAgentDatabasesForTest(); + vi.unstubAllEnvs(); +}); + +afterAll(() => { + cleanupTempDirs(tempDirs); +}); + +describe("prepareWatchedSessionsPrompt", () => { + it("returns key-sorted watched sessions with store-derived titles", async () => { + stubStateDir(); + watchGroup("agent:main:telegram:group:beta"); + watchGroup("agent:main:telegram:group:alpha:topic:7"); + await upsertSessionEntry( + { sessionKey: "agent:main:telegram:group:beta" }, + { sessionId: "session-beta", displayName: "Family group", updatedAt: 1 }, + ); + + const prepared = prepareWatchedSessionsPrompt({ + enabled: true, + sessionKey: mainSessionKey, + toolNames: sessionReadTools, + }); + + expect(prepared).toEqual({ + sessions: [ + { key: "agent:main:telegram:group:alpha:topic:7" }, + { key: "agent:main:telegram:group:beta", title: "Family group" }, + ], + hiddenCount: 0, + readToolNames: ["sessions_history", "sessions_search"], + listToolAvailable: true, + }); + }); + + it("caps rendered rows and reports the overflow count", () => { + stubStateDir(); + for (let index = 0; index < 23; index += 1) { + watchGroup(`agent:main:telegram:group:room-${String(index).padStart(2, "0")}`); + } + + const prepared = prepareWatchedSessionsPrompt({ + enabled: true, + sessionKey: mainSessionKey, + toolNames: ["sessions_history"], + }); + + expect(prepared?.sessions).toHaveLength(20); + expect(prepared?.hiddenCount).toBe(3); + expect(prepared?.sessions[0]?.key).toBe("agent:main:telegram:group:room-00"); + expect(prepared?.readToolNames).toEqual(["sessions_history"]); + expect(prepared?.listToolAvailable).toBe(false); + }); + + it("accepts capability-provided read tools regardless of casing", () => { + stubStateDir(); + watchGroup("agent:main:telegram:group:beta"); + + const prepared = prepareWatchedSessionsPrompt({ + enabled: true, + sessionKey: mainSessionKey, + toolNames: [], + capabilityToolNames: [" Sessions_Search "], + }); + + expect(prepared?.readToolNames).toEqual(["sessions_search"]); + }); + + it("keeps the section for sandboxed sessions only when the clamp allows non-spawned reads", () => { + stubStateDir(); + watchGroup("agent:main:telegram:group:beta"); + const base = { + enabled: true, + sessionKey: mainSessionKey, + sandboxed: true, + toolNames: sessionReadTools, + }; + + expect(prepareWatchedSessionsPrompt({ ...base, config: {} })).toBe(undefined); + expect( + prepareWatchedSessionsPrompt({ + ...base, + config: { agents: { defaults: { sandbox: { sessionToolsVisibility: "all" } } } }, + })?.sessions, + ).toHaveLength(1); + }); + + it("returns undefined when disabled, keyless, non-main, toolless, or unwatched", () => { + stubStateDir(); + watchGroup("agent:main:telegram:group:beta"); + const base = { enabled: true, sessionKey: mainSessionKey, toolNames: sessionReadTools }; + + expect(prepareWatchedSessionsPrompt({ ...base, enabled: false })).toBe(undefined); + expect(prepareWatchedSessionsPrompt({ ...base, sessionKey: undefined })).toBe(undefined); + expect( + prepareWatchedSessionsPrompt({ ...base, sessionKey: "agent:main:subagent:worker" }), + ).toBe(undefined); + expect( + prepareWatchedSessionsPrompt({ ...base, sessionKey: "agent:main:telegram:group:beta" }), + ).toBe(undefined); + expect(prepareWatchedSessionsPrompt({ ...base, toolNames: ["sessions_list"] })).toBe(undefined); + expect(prepareWatchedSessionsPrompt({ ...base, sessionKey: "agent:other:main" })).toBe( + undefined, + ); + }); + + it("renders the harness context block plugin-owned runtimes inject per turn", () => { + stubStateDir(); + watchGroup("agent:main:telegram:group:beta"); + + const block = buildWatchedSessionsHarnessContext({ + sessionKey: mainSessionKey, + toolNames: ["sessions_history"], + }); + + expect(block).toBe( + [ + "## Watched Sessions", + "Group/topic sessions this session ambiently watches. Readable now (read-only) via sessions_history.", + "- agent:main:telegram:group:beta", + ].join("\n"), + ); + expect(buildWatchedSessionsHarnessContext({ sessionKey: mainSessionKey, toolNames: [] })).toBe( + undefined, + ); + }); +}); diff --git a/src/agents/watched-sessions-prompt.ts b/src/agents/watched-sessions-prompt.ts new file mode 100644 index 000000000000..9b7491ac5ac7 --- /dev/null +++ b/src/agents/watched-sessions-prompt.ts @@ -0,0 +1,118 @@ +/** + * Prepares the Watched Sessions system-prompt section (openclaw#114797). + * + * Ambient group watches make same-agent group sessions readable from the main + * session, but the model only acts on that when the prompt names them. Prepare + * runs before synchronous prompt assembly, mirroring prepareAgentMemoryPrompt. + */ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { loadExactSessionEntryReadOnly } from "../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { deriveSessionTitle } from "../gateway/session-utils.js"; +import { resolveSandboxSessionToolsVisibility } from "../plugin-sdk/session-visibility.js"; +import { buildAgentMainSessionKey, parseAgentSessionKey } from "../routing/session-key.js"; +import { listAmbientGroupWatchTargets } from "../sessions/session-state-events.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; + +/** Watched-session facts resolved before synchronous prompt assembly. */ +export type PreparedWatchedSessionsPrompt = { + /** Key-sorted capped rows; deterministic bytes keep the prompt cache stable. */ + sessions: Array<{ key: string; title?: string }>; + /** Watch targets beyond the row cap; rendered as an overflow note. */ + hiddenCount: number; + /** Granted read tools the section may name, in render order. */ + readToolNames: string[]; + listToolAvailable: boolean; +}; + +// Caps prompt bytes for pathological watch counts; the render notes the overflow. +const WATCHED_SESSIONS_PROMPT_LIMIT = 20; +// Titles are external group names; clamp so one hostile rename cannot bloat the prompt. +const WATCHED_SESSION_TITLE_MAX_CHARS = 80; + +const WATCHED_SESSION_READ_TOOLS = ["sessions_history", "sessions_search"]; + +/** Resolve watched same-agent group sessions for the current session's prompt. */ +export function prepareWatchedSessionsPrompt(params: { + enabled: boolean; + config?: OpenClawConfig; + sessionKey?: string; + sandboxed?: boolean; + toolNames: Iterable; + capabilityToolNames?: Iterable; +}): PreparedWatchedSessionsPrompt | undefined { + const sessionKey = params.sessionKey?.trim(); + if (!params.enabled || !sessionKey) { + return undefined; + } + // Ambient watch cursors are written only for buildAgentMainSessionKey watchers + // (registerMainSessionGroupWatch), so any other session key can skip the probe. + const parsedKey = parseAgentSessionKey(sessionKey); + if (!parsedKey || buildAgentMainSessionKey({ agentId: parsedKey.agentId }) !== sessionKey) { + return undefined; + } + // Sandboxed sessions with the default "spawned" clamp list/read only spawned + // rows, so the section would advertise reads that context cannot make. The + // "all" clamp lifts that restriction and keeps the section. + if (params.sandboxed && resolveSandboxSessionToolsVisibility(params.config ?? {}) === "spawned") { + return undefined; + } + const availableTools = new Set( + [...params.toolNames, ...(params.capabilityToolNames ?? [])] + .map((tool) => tool.trim().toLowerCase()) + .filter(Boolean), + ); + const readToolNames = WATCHED_SESSION_READ_TOOLS.filter((tool) => availableTools.has(tool)); + if (readToolNames.length === 0) { + return undefined; + } + // Sorted by key, not recency: recency-ordered rows would reshuffle prompt + // bytes on every group message and defeat provider prompt caching. + const targets = [...listAmbientGroupWatchTargets(sessionKey)].toSorted(); + if (targets.length === 0) { + return undefined; + } + const sessions = targets.slice(0, WATCHED_SESSIONS_PROMPT_LIMIT).map((key) => { + const row: { key: string; title?: string } = { key }; + // Exact persisted-key probe: watch cursors store canonical keys, so the + // alias-resolving loader's full-snapshot scan is wasted work here. + const entry = loadExactSessionEntryReadOnly({ sessionKey: key, clone: false })?.entry; + const title = deriveSessionTitle(entry); + if (title) { + row.title = truncateUtf16Safe(title, WATCHED_SESSION_TITLE_MAX_CHARS); + } + return row; + }); + return { + sessions, + hiddenCount: targets.length - sessions.length, + readToolNames, + listToolAvailable: availableTools.has("sessions_list"), + }; +} + +/** Renders the shared Watched Sessions block used by every prompt-assembly surface. */ +export function buildWatchedSessionsPromptLines( + prepared?: PreparedWatchedSessionsPrompt, +): string[] { + if (!prepared || prepared.sessions.length === 0) { + return []; + } + const listHint = prepared.listToolAvailable ? "; rows appear in sessions_list" : ""; + return [ + "## Watched Sessions", + `Group/topic sessions this session ambiently watches. Readable now (read-only) via ${prepared.readToolNames.join("/")}${listHint}.`, + ...prepared.sessions.map((session) => { + const title = session.title ? ` — ${sanitizeForPromptLiteral(session.title)}` : ""; + return `- ${sanitizeForPromptLiteral(session.key)}${title}`; + }), + ...(prepared.hiddenCount > 0 + ? [ + prepared.listToolAvailable + ? `(+${prepared.hiddenCount} more: sessions_list kinds=["group"].)` + : `(+${prepared.hiddenCount} more.)`, + ] + : []), + "", + ]; +} diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index fe3d96651f74..dc5ccbcc5f58 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -28,6 +28,11 @@ import { } from "../agents/embedded-agent-runner/runs.js"; import type { SandboxFsBridge } from "../agents/sandbox/fs-bridge.js"; import { formatToolDetail, resolveToolDisplay } from "../agents/tool-display.js"; +import { + buildWatchedSessionsPromptLines, + prepareWatchedSessionsPrompt, +} from "../agents/watched-sessions-prompt.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ImageContent } from "../llm/types.js"; import { redactToolDetail } from "../logging/redact.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; @@ -36,6 +41,25 @@ import { truncateUtf16Safe } from "../utils.js"; /** Default truncation limit for user-facing tool progress output. */ export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000; +/** + * Renders the Watched Sessions prompt block for plugin-owned harness prompts. + * Harness runtimes that assemble their own instruction layers (e.g. Codex) + * must surface the same watched-session facts as the embedded prompt, or the + * model keeps refusing cross-session questions on those runtimes (openclaw#114797). + */ +export function buildWatchedSessionsHarnessContext(params: { + config?: OpenClawConfig; + sessionKey?: string; + sandboxed?: boolean; + toolNames: Iterable; + capabilityToolNames?: Iterable; +}): string | undefined { + const lines = buildWatchedSessionsPromptLines( + prepareWatchedSessionsPrompt({ enabled: true, ...params }), + ); + return lines.length > 0 ? lines.join("\n").trimEnd() : undefined; +} + export { FAST_MODE_AUTO_PROGRESS_KIND } from "../auto-reply/reply-payload.js"; export { isDeliveredMessageToolOnlySourceReplyResult, diff --git a/src/plugin-sdk/session-visibility.ts b/src/plugin-sdk/session-visibility.ts index dae5a0bb25d2..cab726086512 100644 --- a/src/plugin-sdk/session-visibility.ts +++ b/src/plugin-sdk/session-visibility.ts @@ -302,7 +302,12 @@ function selfVisibilityMessage(action: SessionAccessAction): string { } function treeVisibilityMessage(action: SessionAccessAction): string { - return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; + // Reads under tree also cover watched same-agent group sessions (isWatchedRead + // below); the deny copy must say so or the model concludes group reads never work. + if (action === "send") { + return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; + } + return `${actionPrefix(action)} visibility is restricted to the current session tree and any watched same-agent group sessions (tools.sessions.visibility=tree).`; } /** Create a direct session-key visibility checker for one requester/action pair. */ diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 73f3ee9d42f2..b5a6fe047733 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -138,7 +138,7 @@ "type": "function" }, { - "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (`tree` default: current + own spawn subtree). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.", + "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index f8ce73fa1b2e..05680f896e37 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -138,7 +138,7 @@ "type": "function" }, { - "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (`tree` default: current + own spawn subtree). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", + "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 5df0d27dd263..2e12b2a23fde 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -138,7 +138,7 @@ "type": "function" }, { - "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (`tree` default: current + own spawn subtree). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", + "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 3516b5c54c13..4a3d22eb6aa9 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 61383, - "roughTokens": 15346 + "chars": 61437, + "roughTokens": 15360 }, "openClawDeveloperInstructions": { "chars": 3471, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6964 }, "totalWithDynamicToolsJson": { - "chars": 89239, - "roughTokens": 22310 + "chars": 89293, + "roughTokens": 22324 }, "userInputText": { "chars": 1300, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index e27d1558689a..db9c25fad014 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 61075, - "roughTokens": 15269 + "chars": 61129, + "roughTokens": 15283 }, "openClawDeveloperInstructions": { "chars": 2362, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6594 }, "totalWithDynamicToolsJson": { - "chars": 87451, - "roughTokens": 21863 + "chars": 87505, + "roughTokens": 21877 }, "userInputText": { "chars": 929, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 8d99e51ecce8..167dabd3477e 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 62607, - "roughTokens": 15652 + "chars": 62661, + "roughTokens": 15666 }, "openClawDeveloperInstructions": { "chars": 2362, @@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6701 }, "totalWithDynamicToolsJson": { - "chars": 89412, - "roughTokens": 22353 + "chars": 89466, + "roughTokens": 22367 }, "userInputText": { "chars": 1284,