fix(agents): make requested threads sidebar-visible (#118073)

* fix(agents): make requested threads sidebar-visible

* fix(agents): preserve visible spawn authority
This commit is contained in:
Jason (Json)
2026-08-02 14:26:24 -06:00
committed by GitHub
parent 57809f4474
commit 01565bdc47
27 changed files with 707 additions and 59 deletions
+1 -1
View File
@@ -255,7 +255,7 @@ their latest assistant turn back to the requester; external delivery stays with
the parent/requester agent.
</Warning>
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. Visible spawning is rejected when the requester was itself spawned with an inherited tool allowlist or denylist; that restriction is fixed at spawn time and has no config override. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. Use this mode when the user asks to create or open a thread that should appear in the sidebar. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. The new dashboard child inherits the requester's effective tool-policy ceiling before its first turn. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
### Task names and targeting
+85
View File
@@ -101,6 +101,36 @@ describe("resolveRequesterToolPolicies", () => {
expect(result.subagentPolicy).toBeDefined();
});
it("uses a persisted projection for a spawn-owned dashboard child", async () => {
const parentSessionKey = "agent:main:main";
const childSessionKey = "agent:main:dashboard:visible-child";
await writeSession(childSessionKey, {
spawnedBy: parentSessionKey,
parentSessionKey,
spawnDepth: 1,
inheritedToolPolicyVersion: 1,
inheritedToolAllow: ["read", "sessions_spawn"],
inheritedToolDeny: ["exec"],
});
const result = resolveRequesterToolPolicies({
config: config(),
agentId: "main",
sessionKey: childSessionKey,
spawnedBy: parentSessionKey,
});
expect(result.delegated).toBe(true);
expect(result.requesterPolicySource).toBe("persisted-child");
expect(result.senderPolicy).toBeUndefined();
expect(result.groupPolicy).toBeUndefined();
expect(result.inheritedToolPolicy).toEqual({
allow: ["read", "sessions_spawn"],
deny: ["exec"],
});
expect(result.subagentPolicy).toBeDefined();
});
it("keeps the sender snapshot while applying current non-sender restrictions", async () => {
const childSessionKey = "agent:main:subagent:web-search";
await writeSession(childSessionKey, {
@@ -417,6 +447,61 @@ describe("resolveRequesterToolPolicies", () => {
expect(controllerResult.requesterPolicySource).toBe("current-request");
});
it("restores a visible dashboard child completion to its immutable owner", async () => {
const controllerSessionKey = "agent:main:discord:direct:alice";
const completionOwnerSessionKey = "agent:main:main";
const childSessionKey = "agent:main:dashboard:visible-child";
await writeSession(childSessionKey, {
spawnedBy: controllerSessionKey,
completionOwnerSessionKey,
spawnDepth: 1,
inheritedToolPolicyVersion: 1,
inheritedToolAllow: ["read", "message"],
inheritedToolDeny: ["exec"],
});
const result = resolveRequesterToolPolicies({
config: config(),
agentId: "main",
sessionKey: completionOwnerSessionKey,
...completionHandoffFacts(childSessionKey, completionOwnerSessionKey),
inputProvenance: {
kind: "inter_session",
sourceSessionKey: childSessionKey,
sourceTool: "subagent_announce",
},
});
expect(result).toMatchObject({
delegated: true,
requesterPolicySource: "completion-handoff",
inheritedToolPolicy: {
allow: ["read", "message"],
deny: ["exec"],
},
});
});
it("does not treat an ordinary dashboard key as a completion authority", async () => {
const childSessionKey = "agent:main:dashboard:operator-thread";
await writeSession(childSessionKey, { spawnDepth: 0 });
const result = resolveRequesterToolPolicies({
config: config(),
agentId: "main",
sessionKey: "agent:main:main",
...completionHandoffFacts(childSessionKey, "agent:main:main"),
inputProvenance: {
kind: "inter_session",
sourceSessionKey: childSessionKey,
sourceTool: "subagent_announce",
},
});
expect(result.delegated).toBe(false);
expect(result.requesterPolicySource).toBe("current-request");
});
it("walks nested lineage to the projection captured from the target requester", async () => {
const requesterSessionKey = "agent:main:discord:direct:alice";
const parentChildSessionKey = "agent:main:subagent:parent-child";
+6
View File
@@ -123,8 +123,14 @@ function resolveDelegatedPolicy(
return { delegated: false };
}
visited.add(currentSessionKey);
// The signed handoff authorizes the one store lookup needed for dashboard
// children; the persisted envelope still has to prove lineage and depth.
const completionStore = resolveSubagentCapabilityStore(currentSessionKey, {
cfg: params.config,
});
const envelope = resolvePersistedSubagentToolPolicyEnvelope(currentSessionKey, {
cfg: params.config,
store: completionStore,
});
if (!envelope) {
return { delegated: false };
+47 -6
View File
@@ -92,6 +92,20 @@ function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean {
return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey);
}
function isDashboardSessionKey(sessionKey: string): boolean {
return parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true;
}
function canInspectStoredSubagentEnvelope(
sessionKey: string,
store?: SessionCapabilityStore,
): boolean {
return (
shouldInspectStoredSubagentEnvelope(sessionKey) ||
(Boolean(store) && isDashboardSessionKey(sessionKey))
);
}
function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean {
const leftAgentId = normalizeOptionalLowercaseString(
parseAgentSessionKey(leftSessionKey)?.agentId,
@@ -140,7 +154,13 @@ export function resolveSubagentCapabilityStore(
if (opts?.store) {
return opts.store;
}
if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
// Dashboard key shape permits only a store lookup. Callers still require a
// persisted spawn envelope before granting subagent authority.
if (
!opts?.cfg ||
(!shouldInspectStoredSubagentEnvelope(normalizedSessionKey) &&
!isDashboardSessionKey(normalizedSessionKey))
) {
return undefined;
}
const parsed = parseAgentSessionKey(normalizedSessionKey);
@@ -204,7 +224,8 @@ function isStoredSubagentEnvelopeSession(
if (isSubagentSessionKey(normalizedSessionKey)) {
return true;
}
if (!isAcpSessionKey(normalizedSessionKey)) {
const dashboardSession = isDashboardSessionKey(normalizedSessionKey);
if (!isAcpSessionKey(normalizedSessionKey) && !dashboardSession) {
return false;
}
@@ -215,6 +236,14 @@ function isStoredSubagentEnvelopeSession(
cfg: params.cfg,
store: params.store,
});
if (dashboardSession) {
return (
typeof entry?.spawnDepth === "number" &&
Number.isInteger(entry.spawnDepth) &&
entry.spawnDepth >= 1 &&
Boolean(normalizeOptionalString(entry.spawnedBy))
);
}
if (
normalizeSubagentRole(entry?.subagentRole) ||
normalizeSubagentControlScope(entry?.subagentControlScope)
@@ -257,7 +286,10 @@ export function isSubagentEnvelopeSession(
if (isSubagentSessionKey(normalizedSessionKey)) {
return true;
}
if (!isAcpSessionKey(normalizedSessionKey)) {
if (!isAcpSessionKey(normalizedSessionKey) && !isDashboardSessionKey(normalizedSessionKey)) {
return false;
}
if (isDashboardSessionKey(normalizedSessionKey) && !opts?.entry && !opts?.store) {
return false;
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
@@ -282,7 +314,10 @@ export function resolvePersistedSubagentToolPolicyEnvelope(
},
): PersistedSubagentToolPolicyEnvelope | undefined {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
if (
!normalizedSessionKey ||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
) {
return undefined;
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
@@ -382,7 +417,10 @@ export function resolveStoredSubagentInheritedToolDenylist(
},
): string[] {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
if (
!normalizedSessionKey ||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
) {
return [];
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
@@ -403,7 +441,10 @@ export function resolveStoredSubagentInheritedToolAllowlist(
},
): string[] {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
if (
!normalizedSessionKey ||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
) {
return [];
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
+1 -1
View File
@@ -109,7 +109,7 @@ export function describeSessionsSpawnTool(options?: {
? '`mode="run"` one-shot; `mode="session"` persistent/thread-bound only on supporting requester channel.'
: '`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`.',
'`visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.',
visibilityLine,
...(options?.swarmEnabled
? [
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { AgentRuntimeSessionSpawnContext } from "../../gateway/agent-runtime-identity-token.js";
const sessionSpawnContext = new AsyncLocalStorage<AgentRuntimeSessionSpawnContext>();
/** Scope signed session-creation authority to one local Gateway tool call. */
export function runWithGatewaySessionSpawnContext<T>(
context: AgentRuntimeSessionSpawnContext,
run: () => Promise<T>,
): Promise<T> {
return sessionSpawnContext.run(context, run);
}
export function getGatewaySessionSpawnContext(): AgentRuntimeSessionSpawnContext | undefined {
return sessionSpawnContext.getStore();
}
@@ -7,6 +7,7 @@ import {
revokeMessageActionTurnCapability,
} from "../../gateway/message-action-turn-capability.js";
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
import { callGatewayTool, resolveMessageActionAgentRuntimeIdentityToken } from "./gateway.js";
const mocks = vi.hoisted(() => ({
@@ -65,6 +66,37 @@ describe("gateway tool runtime identity", () => {
},
);
it("scopes signed session-spawn authority to its Gateway call", async () => {
mocks.callGateway.mockResolvedValueOnce({ key: "agent:ops:dashboard:child" });
await withGatewayToolCallerIdentity(
{ agentId: "ops", sessionKey: "agent:ops:main" },
async () =>
await runWithGatewaySessionSpawnContext(
{
completionOwnerSessionKey: "agent:ops:discord:direct:alice",
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
},
() =>
callGatewayTool(
"sessions.create",
{},
{ parentSessionKey: "agent:ops:main", spawnDepth: 1 },
{ requireAgentRuntimeIdentity: true },
),
),
);
await expect(
verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken),
).resolves.toMatchObject({
sessionSpawnContext: {
completionOwnerSessionKey: "agent:ops:discord:direct:alice",
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
},
});
});
it("mints message action identity only for an exact admitted source turn", async () => {
const capabilityInput = {
agentId: "ops",
+6 -1
View File
@@ -32,6 +32,7 @@ import {
import { formatErrorMessage } from "../../infra/errors.js";
import { readPositiveIntegerParam, readStringParam } from "./common.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
/** Optional gateway connection overrides accepted by agent tools. */
export type GatewayCallOptions = {
@@ -366,7 +367,11 @@ async function resolveAgentRuntimeIdentityTokenForGatewayTool(params: {
throw new Error("agent gateway calls require the trusted local gateway context");
}
try {
return await mintAgentRuntimeIdentityToken(identity);
const sessionSpawnContext = getGatewaySessionSpawnContext();
return await mintAgentRuntimeIdentityToken({
...identity,
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
});
} catch (error) {
if (optionalLocalIdentity && !params.required) {
return undefined;
@@ -18,6 +18,7 @@ vi.mock("../../gateway/server-plugins.js", () => ({
vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool }));
import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js";
describe("trusted in-process Gateway session creation", () => {
@@ -55,4 +56,43 @@ describe("trusted in-process Gateway session creation", () => {
{ scopes: ["operator.write"] },
);
});
it("carries visible-spawn policy through signed identity on fallback dispatch", async () => {
mocks.hasContext = false;
const inheritedToolPolicy = {
version: 1 as const,
allow: ["read", "sessions_spawn"],
deny: ["exec"],
};
mocks.callGatewayTool.mockImplementationOnce(async () => {
expect(getGatewaySessionSpawnContext()).toEqual({
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicy,
});
return { key: "agent:main:dashboard:child" };
});
await callInProcessGatewayToolWithCreation(
"sessions.create",
{ agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 },
{
via: "spawn",
actor: { type: "agent", id: "agent:main:main" },
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicy,
},
);
expect(mocks.callGatewayTool).toHaveBeenCalledWith(
"sessions.create",
{},
{ agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 },
{
scopes: ["operator.write"],
requireAgentRuntimeIdentity: true,
},
);
expect(getGatewaySessionSpawnContext()).toBeUndefined();
});
});
+19 -2
View File
@@ -7,6 +7,7 @@ import {
getInProcessGatewayRequestContext,
hasInProcessGatewayContext,
} from "../../gateway/server-plugins.js";
import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
import { callGatewayTool } from "./gateway.js";
export type InProcessGatewayCaller = <T = Record<string, unknown>>(
@@ -49,6 +50,22 @@ export async function callInProcessGatewayToolWithCreation<T = Record<string, un
syntheticScopes: scopes,
});
}
// The fallback is a real Gateway request; trusted creation metadata never crosses the wire.
return await callGatewayTool<T>(method, {}, params, { scopes });
// The fallback is a real local Gateway request. Carry spawn policy only in
// the signed agent-runtime identity token, never in model-authored params.
if (creation.via !== "spawn" || !creation.inheritedToolPolicy) {
return await callGatewayTool<T>(method, {}, params, { scopes });
}
return await runWithGatewaySessionSpawnContext(
{
...(creation.completionOwnerSessionKey
? { completionOwnerSessionKey: creation.completionOwnerSessionKey }
: {}),
inheritedToolPolicy: creation.inheritedToolPolicy,
},
() =>
callGatewayTool<T>(method, {}, params, {
scopes,
requireAgentRuntimeIdentity: true,
}),
);
}
+34 -9
View File
@@ -8,6 +8,7 @@ import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "../swarm-code-mode.js";
import type { InProcessGatewayCaller } from "./in-process-gateway.js";
const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
@@ -388,11 +389,12 @@ describe("sessions_spawn tool", () => {
};
expect(schema.properties?.visible?.description).toBe(
"Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
"Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
);
expect(tool.description).toContain("`visible=true`: persistent dashboard session");
expect(tool.description).toContain("`visible=true`: persistent sidebar dashboard session");
expect(tool.description).toContain("when the user asks to create/open a thread");
expect(tool.description).toContain('no `mode="run"`');
expect(tool.description).toContain("inherited tool allow/denylist");
expect(tool.description).toContain("inherits the caller tool-policy ceiling");
expect(tool.description).toContain("`tools.sessions.visibility`");
expect(schema.properties?.runtime?.description).toContain("visible=true");
expect(schema.properties?.mode?.description).toContain("Omit with visible=true");
@@ -718,26 +720,49 @@ describe("sessions_spawn tool", () => {
);
});
it("denies visible sessions when tool restrictions cannot carry forward", async () => {
const callGateway = vi.fn();
it("creates visible sessions while carrying inherited tool restrictions forward", async () => {
const callGateway = vi.fn(async () => ({
key: "agent:main:dashboard:restricted-child",
runStarted: true,
runId: "run-visible-restricted",
})) as InProcessGatewayCaller;
const registerRun = vi.fn();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: { agents: { list: [{ id: "main" }] } },
inheritedToolAllowlist: ["read", "sessions_spawn"],
inheritedToolDenylist: ["exec"],
callGateway,
registerRun,
countActiveRuns: () => 0,
});
const result = await tool.execute("visible-restricted", {
task: "inspect",
label: "Track upstream fix",
visible: true,
});
expect(result.details).toMatchObject({
status: "forbidden",
error:
"Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.",
status: "accepted",
childSessionKey: "agent:main:dashboard:restricted-child",
runId: "run-visible-restricted",
});
expect(callGateway).not.toHaveBeenCalled();
expect(callGateway).toHaveBeenCalledWith(
"sessions.create",
expect.objectContaining({
agentId: "main",
label: "Track upstream fix",
parentSessionKey: "agent:main:main",
spawnDepth: 1,
}),
);
expect(registerRun).toHaveBeenCalledWith(
expect.objectContaining({
childSessionKey: "agent:main:dashboard:restricted-child",
runId: "run-visible-restricted",
}),
);
});
it("blocks unsandboxed visible targets for a sandboxed caller runtime", async () => {
+7 -11
View File
@@ -35,7 +35,7 @@ export const VISIBLE_SESSIONS_SPAWN_SCHEMA = {
visible: Type.Optional(
Type.Boolean({
description:
"Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
"Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
}),
),
worktree: Type.Optional(Type.Boolean({ description: "Visible session worktree" })),
@@ -161,16 +161,6 @@ export async function maybeSpawnVisibleSession(params: {
}
const cfg = params.options?.config ?? getRuntimeConfig();
if (
(params.options?.inheritedToolAllowlist?.length ?? 0) > 0 ||
(params.options?.inheritedToolDenylist?.length ?? 0) > 0
) {
return {
status: "forbidden",
error:
"Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.",
};
}
const ownership = resolveSubagentSpawnOwnership({
cfg,
agentSessionKey: params.options?.agentSessionKey,
@@ -288,6 +278,12 @@ export async function maybeSpawnVisibleSession(params: {
callInProcessGatewayToolWithCreation(method, requestParams, {
via: "spawn",
actor: { type: "agent", id: requesterKey },
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
inheritedToolPolicy: {
version: 1,
allow: [...(params.options?.inheritedToolAllowlist ?? [])],
deny: [...(params.options?.inheritedToolDenylist ?? [])],
},
}));
const response = await createGatewayCall<{
key?: string;
@@ -84,6 +84,37 @@ describe("agent runtime identity token", () => {
});
});
it("round-trips a signed visible-session spawn policy", async () => {
useTempHome();
const runtimeToken = await importRuntimeTokenModule();
const token = await runtimeToken.mintAgentRuntimeIdentityToken({
agentId: "main",
sessionKey: "agent:main:main",
sessionSpawnContext: {
completionOwnerSessionKey: " agent:main:discord:direct:alice ",
inheritedToolPolicy: {
version: 1,
allow: [" read ", "sessions_spawn"],
deny: ["exec"],
},
},
});
await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toMatchObject({
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:main",
sessionSpawnContext: {
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicy: {
version: 1,
allow: ["read", "sessions_spawn"],
deny: ["exec"],
},
},
});
});
it("round-trips a short-lived cron self-management capability", async () => {
useTempHome();
const runtimeToken = await importRuntimeTokenModule();
@@ -28,6 +28,16 @@ export type AgentRuntimeIdentity = {
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext;
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
};
export type AgentRuntimeSessionSpawnContext = {
completionOwnerSessionKey?: string;
inheritedToolPolicy: {
version: 1;
allow: string[];
deny: string[];
};
};
type AgentRuntimeIdentityTokenPayload = {
@@ -37,8 +47,36 @@ type AgentRuntimeIdentityTokenPayload = {
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext;
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
};
function decodeStringList(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
return undefined;
}
return value.map((entry) => entry.trim()).filter(Boolean);
}
function decodeSessionSpawnContext(value: unknown): AgentRuntimeSessionSpawnContext | undefined {
if (!isRecord(value) || !isRecord(value.inheritedToolPolicy)) {
return undefined;
}
const policy = value.inheritedToolPolicy;
const allow = decodeStringList(policy.allow);
const deny = decodeStringList(policy.deny);
if (policy.version !== 1 || !allow || !deny) {
return undefined;
}
const completionOwnerSessionKey = normalizeOptionalString(value.completionOwnerSessionKey);
if (value.completionOwnerSessionKey !== undefined && !completionOwnerSessionKey) {
return undefined;
}
return {
...(completionOwnerSessionKey ? { completionOwnerSessionKey } : {}),
inheritedToolPolicy: { version: 1, allow, deny },
};
}
async function readSharedAgentRuntimeIdentitySecret(): Promise<string | null> {
return (await loadExecApprovalsAsync()).socket?.token?.trim() || null;
}
@@ -175,6 +213,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
turnSourceAccountId?: unknown;
messageActionContext?: unknown;
cronSelfManagementContext?: unknown;
sessionSpawnContext?: unknown;
};
if (
raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND ||
@@ -219,6 +258,13 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
if (rawCronSelfManagement !== undefined && !cronSelfManagementContext) {
return undefined;
}
const sessionSpawnContext =
raw.sessionSpawnContext === undefined
? undefined
: decodeSessionSpawnContext(raw.sessionSpawnContext);
if (raw.sessionSpawnContext !== undefined && !sessionSpawnContext) {
return undefined;
}
return {
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
agentId,
@@ -226,6 +272,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
...(messageActionContext ? { messageActionContext } : {}),
...(cronSelfManagementContext ? { cronSelfManagementContext } : {}),
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
};
} catch {
return undefined;
@@ -239,6 +286,7 @@ export async function mintAgentRuntimeIdentityToken(params: {
turnSourceAccountId?: string;
messageActionContext?: AgentRuntimeMessageActionContext;
cronSelfManagementJobId?: string;
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
}): Promise<string> {
if (
params.messageActionContext?.sourceReplyFinal === true &&
@@ -272,6 +320,7 @@ export async function mintAgentRuntimeIdentityToken(params: {
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
...(messageActionContext ? { messageActionContext } : {}),
...(cronSelfManagementContext ? { cronSelfManagementContext } : {}),
...(params.sessionSpawnContext ? { sessionSpawnContext: params.sessionSpawnContext } : {}),
});
const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload);
return `${payload}.${signature}`;
@@ -307,5 +356,6 @@ export async function verifyAgentRuntimeIdentityToken(
...(payload.cronSelfManagementContext
? { cronSelfManagementContext: payload.cronSelfManagementContext }
: {}),
...(payload.sessionSpawnContext ? { sessionSpawnContext: payload.sessionSpawnContext } : {}),
};
}
+23 -4
View File
@@ -4237,6 +4237,26 @@ describe("agent event handler", () => {
);
});
it("includes spawnedBy in chat broadcasts for spawn-owned dashboard sessions", () => {
mockSessionLineage("agent:main:dashboard:visible-child", "agent:main:discord:direct:alice");
const { broadcast, handler, chatRunState } = createHarness({
resolveSessionKeyForRun: () => "agent:main:dashboard:visible-child",
});
registerChatRun(
chatRunState,
"run-dashboard-child",
"agent:main:dashboard:visible-child",
"client-dashboard-child",
);
emitAgentEvent(handler, "run-dashboard-child", "assistant", { text: "visible child" });
expectPayloadFields(chatBroadcastCalls(broadcast)[0]?.[1], {
sessionKey: "agent:main:dashboard:visible-child",
spawnedBy: "agent:main:discord:direct:alice",
});
});
it("skips session row load entirely for session keys that cannot carry lineage", () => {
const { broadcast, handler, chatRunState } = createHarness({
resolveSessionKeyForRun: () => "agent:main:main",
@@ -4254,10 +4274,9 @@ describe("agent event handler", () => {
);
}
// The chat delta path invokes resolveSpawnedBy only. Non-subagent,
// non-acp keys cannot carry spawnedBy (see supportsSpawnLineage in
// sessions-patch.ts), so resolveSpawnedBy must short-circuit without
// ever calling loadGatewaySessionRow on this hot path.
// The chat delta path invokes resolveSpawnedBy only. Main/channel keys
// cannot carry spawn lineage, so resolveSpawnedBy must short-circuit
// without calling loadGatewaySessionRow on this hot path.
expect(loadGatewaySessionRow).not.toHaveBeenCalled();
const chatCalls = chatBroadcastCalls(broadcast);
+9 -6
View File
@@ -26,6 +26,7 @@ import {
import { formatErrorMessage } from "../infra/errors.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { logError } from "../logger.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import {
isAcpSessionKey,
isSubagentSessionKey,
@@ -448,9 +449,9 @@ export function createAgentEventHandler({
}
};
// Only subagent/acp keys can carry spawnedBy (mirrors supportsSpawnLineage in
// sessions-patch.ts). Short-circuit everyone else so high-volume chat streams
// do not touch the session store. Results are cached per sessionKey because
// Native, ACP, and spawn-owned dashboard sessions can carry spawnedBy.
// Short-circuit everyone else so high-volume chat streams do not touch the
// session store. Results are cached per sessionKey because
// spawnedBy is immutable once set and resolveSpawnedBy sits on the hot event
// path (delta, flush, final, agent, seq-gap).
const spawnedByCache = new Map<string, string | null>();
@@ -458,9 +459,11 @@ export function createAgentEventHandler({
if (spawnedByCache.has(sessionKey)) {
return spawnedByCache.get(sessionKey)!;
}
// Non-lineage keys return null without polluting the cache; only
// subagent/ACP results (positive or null) are worth memoising.
if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey)) {
// Non-lineage keys return null without polluting the cache; only eligible
// child-session results (positive or null) are worth memoising.
const isDashboardSession =
parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true;
if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey) && !isDashboardSession) {
return null;
}
let result: string | null = null;
@@ -2,10 +2,19 @@ import type {
SessionCreatedActor,
SessionCreatedVia,
} from "../../config/sessions/session-entry-provenance.js";
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
export type TrustedSessionCreation = {
via: SessionCreatedVia;
actor?: SessionCreatedActor;
/** Immutable completion recipient for a spawn-owned visible session. */
completionOwnerSessionKey?: string;
/** Effective caller tool-policy snapshot for an in-process visible spawn. */
inheritedToolPolicy?: {
version: 1;
allow: string[];
deny: string[];
};
};
/**
@@ -14,7 +23,11 @@ export type TrustedSessionCreation = {
*/
type SessionCreationClient = {
authenticatedUserProfile?: { profileId?: string } | null;
internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation };
internal?: {
syntheticClient?: true;
sessionCreation?: TrustedSessionCreation;
agentRuntimeIdentity?: AgentRuntimeIdentity;
};
};
export function resolveOperatorSessionCreation(
@@ -24,6 +37,20 @@ export function resolveOperatorSessionCreation(
if (options.allowTrustedHint && client?.internal?.sessionCreation) {
return client.internal.sessionCreation;
}
const agentRuntimeIdentity = client?.internal?.agentRuntimeIdentity;
if (options.allowTrustedHint && agentRuntimeIdentity?.sessionSpawnContext) {
return {
via: "spawn",
actor: { type: "agent", id: agentRuntimeIdentity.sessionKey },
...(agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey
? {
completionOwnerSessionKey:
agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey,
}
: {}),
inheritedToolPolicy: agentRuntimeIdentity.sessionSpawnContext.inheritedToolPolicy,
};
}
const profileId = client?.authenticatedUserProfile?.profileId;
// Actor only when proven: a profile-less wire connection may be an agent-tool
// client on a remote topology, so claiming a human actor would misattribute
+27 -1
View File
@@ -408,6 +408,23 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
let runMeta: Record<string, unknown> | undefined;
let messageSeq: number | undefined;
const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const sessionCreation = resolveOperatorSessionCreation(client, { allowTrustedHint: true });
const spawnActorSessionKey =
sessionCreation.via === "spawn" && sessionCreation.actor?.type === "agent"
? normalizeOptionalString(sessionCreation.actor.id)
: undefined;
if (
sessionCreation.inheritedToolPolicy &&
spawnActorSessionKey &&
normalizeOptionalString(p.parentSessionKey) !== spawnActorSessionKey
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "spawn parent must match the trusted agent caller"),
);
return;
}
const allowExistingModelSelection = authorizeOperatorScopesForRequiredScope(
ADMIN_SCOPE,
clientScopes,
@@ -454,6 +471,15 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
allowExistingModelSelection,
parentSessionKey: p.parentSessionKey,
spawnDepth: p.spawnDepth,
spawnToolPolicy:
sessionCreation.via === "spawn" && sessionCreation.inheritedToolPolicy
? {
...sessionCreation.inheritedToolPolicy,
...(sessionCreation.completionOwnerSessionKey
? { completionOwnerSessionKey: sessionCreation.completionOwnerSessionKey }
: {}),
}
: undefined,
spawnedCwd: sessionCwd,
worktree: sessionWorktree
? {
@@ -472,7 +498,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
emitCommandHooks: p.emitCommandHooks,
resetMainWhenUnspecified: !hasInitialTurn,
commandSource: "webchat",
creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
creation: sessionCreation,
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
loadGatewayModelCatalog: () =>
context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }),
+169
View File
@@ -1566,6 +1566,175 @@ test("sessions.create persists declared spawn lineage for spawn-owned creations"
expect(created.payload?.entry?.spawnDepth).toBe(2);
});
test("sessions.create atomically persists trusted visible-spawn tool policy", async () => {
const { storePath } = await createSessionStoreDir();
const parentSessionKey = "agent:main:main";
await writeSessionStore({
entries: {
[parentSessionKey]: sessionStoreEntry("sess-visible-spawn-parent"),
},
});
const created = await directSessionReq<{
key?: string;
entry?: {
label?: string;
spawnedBy?: string;
completionOwnerSessionKey?: string;
parentSessionKey?: string;
spawnDepth?: number;
inheritedToolPolicyVersion?: number;
inheritedToolAllow?: string[];
inheritedToolDeny?: string[];
};
}>(
"sessions.create",
{
agentId: "main",
label: "Restricted visible child",
parentSessionKey,
spawnDepth: 1,
},
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
syntheticClient: true,
sessionCreation: {
via: "spawn",
actor: { type: "agent", id: parentSessionKey },
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicy: {
version: 1,
allow: ["read", "sessions_spawn"],
deny: ["exec"],
},
},
},
} as never,
},
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
expect(created.payload?.entry).toMatchObject({
label: "Restricted visible child",
spawnedBy: parentSessionKey,
completionOwnerSessionKey: "agent:main:discord:direct:alice",
parentSessionKey,
spawnDepth: 1,
inheritedToolPolicyVersion: 1,
inheritedToolAllow: ["read", "sessions_spawn"],
inheritedToolDeny: ["exec"],
});
const key = requireNonEmptyString(created.payload?.key, "visible child key");
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
spawnedBy: parentSessionKey,
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicyVersion: 1,
inheritedToolAllow: ["read", "sessions_spawn"],
inheritedToolDeny: ["exec"],
});
});
test("sessions.create accepts a signed agent-runtime visible-spawn policy", async () => {
const { storePath } = await createSessionStoreDir();
const parentSessionKey = "agent:main:main";
await writeSessionStore({
entries: {
[parentSessionKey]: sessionStoreEntry("sess-runtime-spawn-parent"),
},
});
const created = await directSessionReq<{
key?: string;
entry?: {
createdVia?: string;
createdActor?: unknown;
spawnedBy?: string;
completionOwnerSessionKey?: string;
inheritedToolAllow?: string[];
inheritedToolDeny?: string[];
};
}>(
"sessions.create",
{
agentId: "main",
label: "Runtime visible child",
parentSessionKey,
spawnDepth: 1,
},
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
agentRuntimeIdentity: {
kind: "agentRuntime",
agentId: "main",
sessionKey: parentSessionKey,
sessionSpawnContext: {
completionOwnerSessionKey: "agent:main:discord:direct:bob",
inheritedToolPolicy: {
version: 1,
allow: ["read", "sessions_spawn"],
deny: ["exec"],
},
},
},
},
} as never,
},
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
expect(created.payload?.entry).toMatchObject({
createdVia: "spawn",
createdActor: { type: "agent", id: parentSessionKey },
spawnedBy: parentSessionKey,
completionOwnerSessionKey: "agent:main:discord:direct:bob",
inheritedToolAllow: ["read", "sessions_spawn"],
inheritedToolDeny: ["exec"],
});
const key = requireNonEmptyString(created.payload?.key, "runtime visible child key");
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
spawnedBy: parentSessionKey,
completionOwnerSessionKey: "agent:main:discord:direct:bob",
inheritedToolPolicyVersion: 1,
});
});
test("sessions.create rejects a trusted spawn whose parent differs from its agent caller", async () => {
await createSessionStoreDir();
const created = await directSessionReq(
"sessions.create",
{
agentId: "main",
parentSessionKey: "agent:main:other",
spawnDepth: 1,
},
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
agentRuntimeIdentity: {
kind: "agentRuntime",
agentId: "main",
sessionKey: "agent:main:main",
sessionSpawnContext: {
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
},
},
},
} as never,
},
);
expect(created.ok).toBe(false);
expect(created.error?.message).toContain("spawn parent must match the trusted agent caller");
});
test("sessions.create rejects spawnDepth without parentSessionKey", async () => {
await createSessionStoreDir();
@@ -130,6 +130,10 @@ const ownedChildMetadata = {
groupChannel: "dev",
space: "hq",
spawnedBy: "agent:main:main",
completionOwnerSessionKey: "agent:main:discord:direct:alice",
inheritedToolPolicyVersion: 1,
inheritedToolAllow: ["read", "message"],
inheritedToolDeny: ["exec"],
spawnedWorkspaceDir: "/tmp/child-workspace",
spawnedCwd: "/tmp/task-repo",
parentSessionKey: "agent:main:main",
+52
View File
@@ -17,6 +17,10 @@ import {
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js";
import {
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "../agents/inherited-tool-deny.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
import {
resolveDefaultModelForAgent,
@@ -289,6 +293,13 @@ export async function createGatewaySession(params: {
* operator sessions and forks stay spawn-capable roots.
*/
spawnDepth?: number;
/** Trusted effective policy captured by an in-process visible spawn. */
spawnToolPolicy?: {
version: 1;
completionOwnerSessionKey?: string;
allow: string[];
deny: string[];
};
spawnedCwd?: string;
/** Managed worktree bound to the new session; persisted alongside spawnedCwd. */
worktree?: { id: string; branch: string; repoRoot: string };
@@ -495,6 +506,12 @@ export async function createGatewaySession(params: {
};
}
}
if (params.spawnToolPolicy && params.spawnDepth === undefined) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "spawn tool policy requires spawnDepth"),
};
}
let canonicalParentSessionKey: string | undefined;
let parentSessionEntry: SessionEntry | undefined;
let parentSelectedAgentId: string | undefined;
@@ -705,6 +722,17 @@ export async function createGatewaySession(params: {
let createdContext: CreatedGatewaySession | undefined;
let createdNewEntry = false;
const spawnToolPolicy =
params.spawnToolPolicy && canonicalParentSessionKey
? {
completionOwnerSessionKey: normalizeOptionalString(
params.spawnToolPolicy.completionOwnerSessionKey,
),
allow: normalizeInheritedToolAllowlist(params.spawnToolPolicy.allow),
deny: normalizeInheritedToolDenylist(params.spawnToolPolicy.deny),
parentSessionKey: canonicalParentSessionKey,
}
: undefined;
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
let currentParentSessionEntry = parentSessionEntry;
if (
@@ -857,6 +885,15 @@ export async function createGatewaySession(params: {
),
};
}
if (spawnToolPolicy && existingEntry !== undefined) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"spawn tool policy requires a new session",
),
};
}
if (
params.visibility &&
existingEntry === undefined &&
@@ -1030,6 +1067,21 @@ export async function createGatewaySession(params: {
// and plugin sessions) persists as a depth-0 root. Reused entries keep
// their stored depth.
...(existingEntry === undefined ? { spawnDepth: params.spawnDepth ?? 0 } : {}),
...(existingEntry === undefined && spawnToolPolicy
? {
spawnedBy: spawnToolPolicy.parentSessionKey,
...(spawnToolPolicy.completionOwnerSessionKey
? { completionOwnerSessionKey: spawnToolPolicy.completionOwnerSessionKey }
: {}),
inheritedToolPolicyVersion: 1 as const,
...(spawnToolPolicy.allow.length > 0
? { inheritedToolAllow: spawnToolPolicy.allow }
: {}),
...(spawnToolPolicy.deny.length > 0
? { inheritedToolDeny: spawnToolPolicy.deny }
: {}),
}
: {}),
...(existingEntry === undefined && incognito ? { incognito: true as const } : {}),
};
sessionEntries[target.canonicalKey] = initializedEntry;
+4
View File
@@ -1445,6 +1445,10 @@ export async function performGatewaySessionReset(params: {
queueCap: currentEntry?.queueCap,
queueDrop: currentEntry?.queueDrop,
spawnedBy: currentEntry?.spawnedBy,
completionOwnerSessionKey: currentEntry?.completionOwnerSessionKey,
inheritedToolPolicyVersion: currentEntry?.inheritedToolPolicyVersion,
inheritedToolAllow: currentEntry?.inheritedToolAllow,
inheritedToolDeny: currentEntry?.inheritedToolDeny,
spawnedWorkspaceDir: currentEntry?.spawnedWorkspaceDir,
spawnedCwd: params.clearSpawnedCwd
? undefined
@@ -2,7 +2,7 @@
"base": "codex-dynamic-tools.telegram-direct.json",
"replace": {
"sessions_spawn": {
"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.",
"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 sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; 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": {
@@ -101,7 +101,7 @@
"type": "boolean"
},
"visible": {
"description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
"description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
"type": "boolean"
},
"worktree": {
@@ -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: 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.",
"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 sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; 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": {
@@ -233,7 +233,7 @@
"type": "string"
},
"visible": {
"description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
"description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
"type": "boolean"
},
"worktree": {
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 61456,
"roughTokens": 15364
"chars": 61490,
"roughTokens": 15373
},
"openClawDeveloperInstructions": {
"chars": 3811,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 7049
},
"totalWithDynamicToolsJson": {
"chars": 89652,
"roughTokens": 22413
"chars": 89686,
"roughTokens": 22422
},
"userInputText": {
"chars": 1300,
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 61148,
"roughTokens": 15287
"chars": 61182,
"roughTokens": 15296
},
"openClawDeveloperInstructions": {
"chars": 2702,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6679
},
"totalWithDynamicToolsJson": {
"chars": 87864,
"roughTokens": 21966
"chars": 87898,
"roughTokens": 21975
},
"userInputText": {
"chars": 929,
@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 62682,
"roughTokens": 15671
"chars": 62716,
"roughTokens": 15679
},
"openClawDeveloperInstructions": {
"chars": 2702,
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6783
},
"totalWithDynamicToolsJson": {
"chars": 89814,
"roughTokens": 22454
"chars": 89848,
"roughTokens": 22462
},
"userInputText": {
"chars": 1271,