feat(agents): surface watched-session awareness to the model (#114835)

This commit is contained in:
Ayaan Zaidi
2026-07-29 07:39:20 +09:00
committed by GitHub
parent 661efb67a3
commit 895bf61ea0
29 changed files with 569 additions and 31 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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.

View File

@@ -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,

View File

@@ -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(

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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();

View File

@@ -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. */

View File

@@ -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(

View File

@@ -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<SessionVisibilityScope, string>;
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.",

View File

@@ -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(

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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.",
});
});

View File

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

View File

@@ -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<string>;
capabilityToolNames?: Iterable<string>;
}): 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.)`,
]
: []),
"",
];
}

View File

@@ -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<string>;
capabilityToolNames?: Iterable<string>;
}): 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,

View File

@@ -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. */

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,