From aad014c7c1fa3db5d9634c7f3ed781e3c7c012e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 11 Mar 2026 01:44:25 +0000 Subject: [PATCH] fix: harden subagent control boundaries --- CHANGELOG.md | 1 + docs/tools/subagents.md | 1 + .../openclaw-tools.subagents.scope.test.ts | 93 +++ ...agents.sessions-spawn-depth-limits.test.ts | 7 +- .../openclaw-tools.subagents.test-harness.ts | 5 - src/agents/pi-tools.policy.test.ts | 39 + src/agents/pi-tools.policy.ts | 32 + src/agents/pi-tools.ts | 8 +- src/agents/subagent-capabilities.ts | 156 ++++ src/agents/subagent-control.ts | 768 ++++++++++++++++++ src/agents/subagent-registry-queries.ts | 21 +- src/agents/subagent-registry.ts | 10 + src/agents/subagent-registry.types.ts | 1 + src/agents/subagent-spawn.ts | 12 +- src/agents/tools/subagents-tool.ts | 695 ++-------------- src/auto-reply/reply/abort.ts | 4 +- src/auto-reply/reply/commands-subagents.ts | 4 +- .../reply/commands-subagents/action-kill.ts | 71 +- .../reply/commands-subagents/action-list.ts | 77 +- .../reply/commands-subagents/action-send.ts | 142 +--- .../reply/commands-subagents/shared.ts | 26 + src/cli/daemon-cli/lifecycle.test.ts | 21 +- src/config/sessions/types.ts | 4 + src/gateway/protocol/schema/sessions.ts | 6 + src/gateway/sessions-patch.ts | 58 ++ test/setup.ts | 6 + 26 files changed, 1389 insertions(+), 879 deletions(-) create mode 100644 src/agents/subagent-capabilities.ts create mode 100644 src/agents/subagent-control.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 609d16bed3a..221120f09d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. - Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. - Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. +- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey. - ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman. - Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab. - Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d5ec66b884b..dabfc91dfc2 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -182,6 +182,7 @@ Each level only sees announces from its direct children. ### Tool policy by depth +- Role and control scope are written into session metadata at spawn time. That keeps flat or restored session keys from accidentally regaining orchestrator privileges. - **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied. - **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior). - **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children. diff --git a/src/agents/openclaw-tools.subagents.scope.test.ts b/src/agents/openclaw-tools.subagents.scope.test.ts index c58708ee6f4..c985f1712e1 100644 --- a/src/agents/openclaw-tools.subagents.scope.test.ts +++ b/src/agents/openclaw-tools.subagents.scope.test.ts @@ -149,4 +149,97 @@ describe("openclaw-tools: subagents scope isolation", () => { }), ]); }); + + it("leaf subagents cannot kill even explicitly-owned child sessions", async () => { + const leafKey = "agent:main:subagent:leaf"; + const childKey = `${leafKey}:subagent:child`; + + writeStore(storePath, { + [leafKey]: { + sessionId: "leaf-session", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + subagentRole: "leaf", + subagentControlScope: "none", + }, + [childKey]: { + sessionId: "child-session", + updatedAt: Date.now(), + spawnedBy: leafKey, + subagentRole: "leaf", + subagentControlScope: "none", + }, + }); + + addSubagentRunForTests({ + runId: "run-child", + childSessionKey: childKey, + controllerSessionKey: leafKey, + requesterSessionKey: leafKey, + requesterDisplayKey: leafKey, + task: "impossible child", + cleanup: "keep", + createdAt: Date.now() - 30_000, + startedAt: Date.now() - 30_000, + }); + + const tool = createSubagentsTool({ agentSessionKey: leafKey }); + const result = await tool.execute("call-leaf-kill", { + action: "kill", + target: childKey, + }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "Leaf subagents cannot control other sessions.", + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("leaf subagents cannot steer even explicitly-owned child sessions", async () => { + const leafKey = "agent:main:subagent:leaf"; + const childKey = `${leafKey}:subagent:child`; + + writeStore(storePath, { + [leafKey]: { + sessionId: "leaf-session", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + subagentRole: "leaf", + subagentControlScope: "none", + }, + [childKey]: { + sessionId: "child-session", + updatedAt: Date.now(), + spawnedBy: leafKey, + subagentRole: "leaf", + subagentControlScope: "none", + }, + }); + + addSubagentRunForTests({ + runId: "run-child", + childSessionKey: childKey, + controllerSessionKey: leafKey, + requesterSessionKey: leafKey, + requesterDisplayKey: leafKey, + task: "impossible child", + cleanup: "keep", + createdAt: Date.now() - 30_000, + startedAt: Date.now() - 30_000, + }); + + const tool = createSubagentsTool({ agentSessionKey: leafKey }); + const result = await tool.execute("call-leaf-steer", { + action: "steer", + target: childKey, + message: "continue", + }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: "Leaf subagents cannot control other sessions.", + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index 69a1a913b2c..b9c86bf7472 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -6,11 +6,6 @@ import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagen import { createPerSenderSessionConfig } from "./test-helpers/session-config.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: () => undefined, - getOAuthProviders: () => [], -})); - const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ @@ -121,6 +116,8 @@ describe("sessions_spawn depth + child limits", () => { (entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2, ); expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/); + expect(spawnDepthPatch?.params?.subagentRole).toBe("leaf"); + expect(spawnDepthPatch?.params?.subagentControlScope).toBe("none"); }); it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => { diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts index 36f00e22961..44b6ea79118 100644 --- a/src/agents/openclaw-tools.subagents.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -5,11 +5,6 @@ export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["lo export const callGatewayMock: MockFn = vi.fn(); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: () => undefined, - getOAuthProviders: () => [], -})); - const defaultConfig: LoadedConfig = { session: { mainKey: "main", diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts index ac31ca18694..846044c41c0 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/pi-tools.policy.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -5,6 +8,7 @@ import { isToolAllowedByPolicyName, resolveEffectiveToolPolicy, resolveSubagentToolPolicy, + resolveSubagentToolPolicyForSession, } from "./pi-tools.policy.js"; import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; @@ -165,6 +169,41 @@ describe("resolveSubagentToolPolicy depth awareness", () => { expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false); }); + it("uses stored leaf role for flat depth-1 session keys", () => { + const storePath = path.join( + os.tmpdir(), + `openclaw-subagent-policy-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync( + storePath, + JSON.stringify( + { + "agent:main:subagent:flat-leaf": { + sessionId: "flat-leaf", + updatedAt: Date.now(), + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + }, + }, + null, + 2, + ), + "utf-8", + ); + const cfg = { + ...baseCfg, + session: { + store: storePath, + }, + } as unknown as OpenClawConfig; + + const policy = resolveSubagentToolPolicyForSession(cfg, "agent:main:subagent:flat-leaf"); + expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); + expect(isToolAllowedByPolicyName("subagents", policy)).toBe(false); + }); + it("defaults to leaf behavior when no depth is provided", () => { const policy = resolveSubagentToolPolicy(baseCfg); // Default depth=1, maxSpawnDepth=2 → orchestrator diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index d5cf592428e..0353c454865 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -11,6 +11,10 @@ import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; +import { + resolveStoredSubagentCapabilities, + type SubagentSessionRole, +} from "./subagent-capabilities.js"; import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; function makeToolPolicyMatcher(policy: SandboxToolPolicy) { @@ -89,6 +93,13 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] return [...SUBAGENT_TOOL_DENY_ALWAYS]; } +function resolveSubagentDenyListForRole(role: SubagentSessionRole): string[] { + if (role === "leaf") { + return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF]; + } + return [...SUBAGENT_TOOL_DENY_ALWAYS]; +} + export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy { const configured = cfg?.tools?.subagents?.tools; const maxSpawnDepth = @@ -108,6 +119,27 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): return { allow: mergedAllow, deny }; } +export function resolveSubagentToolPolicyForSession( + cfg: OpenClawConfig | undefined, + sessionKey: string, +): SandboxToolPolicy { + const configured = cfg?.tools?.subagents?.tools; + const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg }); + const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; + const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined; + const explicitAllow = new Set( + [...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)), + ); + const deny = [ + ...resolveSubagentDenyListForRole(capabilities.role).filter( + (toolName) => !explicitAllow.has(normalizeToolName(toolName)), + ), + ...(Array.isArray(configured?.deny) ? configured.deny : []), + ]; + const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow; + return { allow: mergedAllow, deny }; +} + export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { if (!policy) { return true; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ff71b53baf4..a89aff3d9dd 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -24,7 +24,7 @@ import { isToolAllowedByPolicies, resolveEffectiveToolPolicy, resolveGroupToolPolicy, - resolveSubagentToolPolicy, + resolveSubagentToolPolicyForSession, } from "./pi-tools.policy.js"; import { assertRequiredParams, @@ -45,7 +45,6 @@ import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.sc import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; import { isXaiProvider } from "./schema/clean-for-xai.js"; -import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js"; import { applyToolPolicyPipeline, @@ -321,10 +320,7 @@ export function createOpenClawCodingTools(options?: { options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined); const subagentPolicy = isSubagentSessionKey(options?.sessionKey) && options?.sessionKey - ? resolveSubagentToolPolicy( - options.config, - getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }), - ) + ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey) : undefined; const allowBackground = isToolAllowedByPolicies("process", [ profilePolicyWithAlsoAllow, diff --git a/src/agents/subagent-capabilities.ts b/src/agents/subagent-capabilities.ts new file mode 100644 index 00000000000..5350b4f6321 --- /dev/null +++ b/src/agents/subagent-capabilities.ts @@ -0,0 +1,156 @@ +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; + +export const SUBAGENT_SESSION_ROLES = ["main", "orchestrator", "leaf"] as const; +export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number]; + +export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const; +export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number]; + +type SessionCapabilityEntry = { + sessionId?: unknown; + spawnDepth?: unknown; + subagentRole?: unknown; + subagentControlScope?: unknown; +}; + +function normalizeSessionKey(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim().toLowerCase(); + return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed); +} + +function normalizeSubagentControlScope(value: unknown): SubagentControlScope | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim().toLowerCase(); + return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed); +} + +function readSessionStore(storePath: string): Record { + try { + return loadSessionStore(storePath); + } catch { + return {}; + } +} + +function findEntryBySessionId( + store: Record, + sessionId: string, +): SessionCapabilityEntry | undefined { + const normalizedSessionId = normalizeSessionKey(sessionId); + if (!normalizedSessionId) { + return undefined; + } + for (const entry of Object.values(store)) { + const candidateSessionId = normalizeSessionKey(entry?.sessionId); + if (candidateSessionId === normalizedSessionId) { + return entry; + } + } + return undefined; +} + +function resolveSessionCapabilityEntry(params: { + sessionKey: string; + cfg?: OpenClawConfig; + store?: Record; +}): SessionCapabilityEntry | undefined { + if (params.store) { + return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey); + } + if (!params.cfg) { + return undefined; + } + const parsed = parseAgentSessionKey(params.sessionKey); + if (!parsed?.agentId) { + return undefined; + } + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed.agentId }); + const store = readSessionStore(storePath); + return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey); +} + +export function resolveSubagentRoleForDepth(params: { + depth: number; + maxSpawnDepth?: number; +}): SubagentSessionRole { + const depth = Number.isInteger(params.depth) ? Math.max(0, params.depth) : 0; + const maxSpawnDepth = + typeof params.maxSpawnDepth === "number" && Number.isFinite(params.maxSpawnDepth) + ? Math.max(1, Math.floor(params.maxSpawnDepth)) + : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + if (depth <= 0) { + return "main"; + } + return depth < maxSpawnDepth ? "orchestrator" : "leaf"; +} + +export function resolveSubagentControlScopeForRole( + role: SubagentSessionRole, +): SubagentControlScope { + return role === "leaf" ? "none" : "children"; +} + +export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDepth?: number }) { + const role = resolveSubagentRoleForDepth(params); + const controlScope = resolveSubagentControlScopeForRole(role); + return { + depth: Math.max(0, Math.floor(params.depth)), + role, + controlScope, + canSpawn: role === "main" || role === "orchestrator", + canControlChildren: controlScope === "children", + }; +} + +export function resolveStoredSubagentCapabilities( + sessionKey: string | undefined | null, + opts?: { + cfg?: OpenClawConfig; + store?: Record; + }, +) { + const normalizedSessionKey = normalizeSessionKey(sessionKey); + const maxSpawnDepth = + opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { + cfg: opts?.cfg, + store: opts?.store, + }); + if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) { + return resolveSubagentCapabilities({ depth, maxSpawnDepth }); + } + const entry = resolveSessionCapabilityEntry({ + sessionKey: normalizedSessionKey, + cfg: opts?.cfg, + store: opts?.store, + }); + const storedRole = normalizeSubagentRole(entry?.subagentRole); + const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope); + const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth }); + const role = storedRole ?? fallback.role; + const controlScope = storedControlScope ?? resolveSubagentControlScopeForRole(role); + return { + depth, + role, + controlScope, + canSpawn: role === "main" || role === "orchestrator", + canControlChildren: controlScope === "children", + }; +} diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts new file mode 100644 index 00000000000..528a84eebd3 --- /dev/null +++ b/src/agents/subagent-control.ts @@ -0,0 +1,768 @@ +import crypto from "node:crypto"; +import { clearSessionQueues } from "../auto-reply/reply/queue.js"; +import { + resolveSubagentLabel, + resolveSubagentTargetFromRuns, + sortSubagentRuns, + type SubagentTargetResolution, +} from "../auto-reply/reply/subagents-utils.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { loadSessionStore, resolveStorePath, updateSessionStore } from "../config/sessions.js"; +import { callGateway } from "../gateway/call.js"; +import { logVerbose } from "../globals.js"; +import { + isSubagentSessionKey, + parseAgentSessionKey, + type ParsedAgentSessionKey, +} from "../routing/session-key.js"; +import { + formatDurationCompact, + formatTokenUsageDisplay, + resolveTotalTokens, + truncateLine, +} from "../shared/subagents-format.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; +import { AGENT_LANE_SUBAGENT } from "./lanes.js"; +import { abortEmbeddedPiRun } from "./pi-embedded.js"; +import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js"; +import { + clearSubagentRunSteerRestart, + countPendingDescendantRuns, + listSubagentRunsForController, + markSubagentRunTerminated, + markSubagentRunForSteerRestart, + replaceSubagentRunAfterSteer, + type SubagentRunRecord, +} from "./subagent-registry.js"; +import { + extractAssistantText, + resolveInternalSessionKey, + resolveMainSessionAlias, + stripToolMessages, +} from "./tools/sessions-helpers.js"; + +export const DEFAULT_RECENT_MINUTES = 30; +export const MAX_RECENT_MINUTES = 24 * 60; +export const MAX_STEER_MESSAGE_CHARS = 4_000; +export const STEER_RATE_LIMIT_MS = 2_000; +export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; + +const steerRateLimit = new Map(); + +export type SessionEntryResolution = { + storePath: string; + entry: SessionEntry | undefined; +}; + +export type ResolvedSubagentController = { + controllerSessionKey: string; + callerSessionKey: string; + callerIsSubagent: boolean; + controlScope: "children" | "none"; +}; + +export type SubagentListItem = { + index: number; + line: string; + runId: string; + sessionKey: string; + label: string; + task: string; + status: string; + pendingDescendants: number; + runtime: string; + runtimeMs: number; + model?: string; + totalTokens?: number; + startedAt?: number; + endedAt?: number; +}; + +export type BuiltSubagentList = { + total: number; + active: SubagentListItem[]; + recent: SubagentListItem[]; + text: string; +}; + +function resolveStorePathForKey( + cfg: OpenClawConfig, + key: string, + parsed?: ParsedAgentSessionKey | null, +) { + return resolveStorePath(cfg.session?.store, { + agentId: parsed?.agentId, + }); +} + +export function resolveSessionEntryForKey(params: { + cfg: OpenClawConfig; + key: string; + cache: Map>; +}): SessionEntryResolution { + const parsed = parseAgentSessionKey(params.key); + const storePath = resolveStorePathForKey(params.cfg, params.key, parsed); + let store = params.cache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.cache.set(storePath, store); + } + return { + storePath, + entry: store[params.key], + }; +} + +export function resolveSubagentController(params: { + cfg: OpenClawConfig; + agentSessionKey?: string; +}): ResolvedSubagentController { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const callerRaw = params.agentSessionKey?.trim() || alias; + const callerSessionKey = resolveInternalSessionKey({ + key: callerRaw, + alias, + mainKey, + }); + if (!isSubagentSessionKey(callerSessionKey)) { + return { + controllerSessionKey: callerSessionKey, + callerSessionKey, + callerIsSubagent: false, + controlScope: "children", + }; + } + const capabilities = resolveStoredSubagentCapabilities(callerSessionKey, { + cfg: params.cfg, + }); + return { + controllerSessionKey: callerSessionKey, + callerSessionKey, + callerIsSubagent: true, + controlScope: capabilities.controlScope, + }; +} + +export function listControlledSubagentRuns(controllerSessionKey: string): SubagentRunRecord[] { + return sortSubagentRuns(listSubagentRunsForController(controllerSessionKey)); +} + +export function createPendingDescendantCounter() { + const pendingDescendantCache = new Map(); + return (sessionKey: string) => { + if (pendingDescendantCache.has(sessionKey)) { + return pendingDescendantCache.get(sessionKey) ?? 0; + } + const pending = Math.max(0, countPendingDescendantRuns(sessionKey)); + pendingDescendantCache.set(sessionKey, pending); + return pending; + }; +} + +export function isActiveSubagentRun( + entry: SubagentRunRecord, + pendingDescendantCount: (sessionKey: string) => number, +) { + return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0; +} + +function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) { + const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0); + if (pendingDescendants > 0) { + const childLabel = pendingDescendants === 1 ? "child" : "children"; + return `active (waiting on ${pendingDescendants} ${childLabel})`; + } + if (!entry.endedAt) { + return "running"; + } + const status = entry.outcome?.status ?? "done"; + if (status === "ok") { + return "done"; + } + if (status === "error") { + return "failed"; + } + return status; +} + +function resolveModelRef(entry?: SessionEntry) { + const model = typeof entry?.model === "string" ? entry.model.trim() : ""; + const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; + if (model.includes("/")) { + return model; + } + if (model && provider) { + return `${provider}/${model}`; + } + if (model) { + return model; + } + if (provider) { + return provider; + } + const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; + const overrideProvider = + typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; + if (overrideModel.includes("/")) { + return overrideModel; + } + if (overrideModel && overrideProvider) { + return `${overrideProvider}/${overrideModel}`; + } + if (overrideModel) { + return overrideModel; + } + return overrideProvider || undefined; +} + +function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { + const modelRef = resolveModelRef(entry) || fallbackModel || undefined; + if (!modelRef) { + return "model n/a"; + } + const slash = modelRef.lastIndexOf("/"); + if (slash >= 0 && slash < modelRef.length - 1) { + return modelRef.slice(slash + 1); + } + return modelRef; +} + +function buildListText(params: { + active: Array<{ line: string }>; + recent: Array<{ line: string }>; + recentMinutes: number; +}) { + const lines: string[] = []; + lines.push("active subagents:"); + if (params.active.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.active.map((entry) => entry.line)); + } + lines.push(""); + lines.push(`recent (last ${params.recentMinutes}m):`); + if (params.recent.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.recent.map((entry) => entry.line)); + } + return lines.join("\n"); +} + +export function buildSubagentList(params: { + cfg: OpenClawConfig; + runs: SubagentRunRecord[]; + recentMinutes: number; + taskMaxChars?: number; +}): BuiltSubagentList { + const now = Date.now(); + const recentCutoff = now - params.recentMinutes * 60_000; + const cache = new Map>(); + const pendingDescendantCount = createPendingDescendantCounter(); + let index = 1; + const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => { + const sessionEntry = resolveSessionEntryForKey({ + cfg: params.cfg, + key: entry.childSessionKey, + cache, + }).entry; + const totalTokens = resolveTotalTokens(sessionEntry); + const usageText = formatTokenUsageDisplay(sessionEntry); + const pendingDescendants = pendingDescendantCount(entry.childSessionKey); + const status = resolveRunStatus(entry, { + pendingDescendants, + }); + const runtime = formatDurationCompact(runtimeMs); + const label = truncateLine(resolveSubagentLabel(entry), 48); + const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + const view: SubagentListItem = { + index, + line, + runId: entry.runId, + sessionKey: entry.childSessionKey, + label, + task, + status, + pendingDescendants, + runtime, + runtimeMs, + model: resolveModelRef(sessionEntry) || entry.model, + totalTokens, + startedAt: entry.startedAt, + ...(entry.endedAt ? { endedAt: entry.endedAt } : {}), + }; + index += 1; + return view; + }; + const active = params.runs + .filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount)) + .map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt))); + const recent = params.runs + .filter( + (entry) => + !isActiveSubagentRun(entry, pendingDescendantCount) && + !!entry.endedAt && + (entry.endedAt ?? 0) >= recentCutoff, + ) + .map((entry) => + buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)), + ); + return { + total: params.runs.length, + active, + recent, + text: buildListText({ active, recent, recentMinutes: params.recentMinutes }), + }; +} + +function ensureControllerOwnsRun(params: { + controller: ResolvedSubagentController; + entry: SubagentRunRecord; +}) { + const owner = params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey; + if (owner === params.controller.controllerSessionKey) { + return undefined; + } + return "Subagents can only control runs spawned from their own session."; +} + +async function killSubagentRun(params: { + cfg: OpenClawConfig; + entry: SubagentRunRecord; + cache: Map>; +}): Promise<{ killed: boolean; sessionId?: string }> { + if (params.entry.endedAt) { + return { killed: false }; + } + const childSessionKey = params.entry.childSessionKey; + const resolved = resolveSessionEntryForKey({ + cfg: params.cfg, + key: childSessionKey, + cache: params.cache, + }); + const sessionId = resolved.entry?.sessionId; + const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; + const cleared = clearSessionQueues([childSessionKey, sessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents control kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + if (resolved.entry) { + await updateSessionStore(resolved.storePath, (store) => { + const current = store[childSessionKey]; + if (!current) { + return; + } + current.abortedLastRun = true; + current.updatedAt = Date.now(); + store[childSessionKey] = current; + }); + } + const marked = markSubagentRunTerminated({ + runId: params.entry.runId, + childSessionKey, + reason: "killed", + }); + const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0; + return { killed, sessionId }; +} + +async function cascadeKillChildren(params: { + cfg: OpenClawConfig; + parentChildSessionKey: string; + cache: Map>; + seenChildSessionKeys?: Set; +}): Promise<{ killed: number; labels: string[] }> { + const childRuns = listSubagentRunsForController(params.parentChildSessionKey); + const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set(); + let killed = 0; + const labels: string[] = []; + + for (const run of childRuns) { + const childKey = run.childSessionKey?.trim(); + if (!childKey || seenChildSessionKeys.has(childKey)) { + continue; + } + seenChildSessionKeys.add(childKey); + + if (!run.endedAt) { + const stopResult = await killSubagentRun({ + cfg: params.cfg, + entry: run, + cache: params.cache, + }); + if (stopResult.killed) { + killed += 1; + labels.push(resolveSubagentLabel(run)); + } + } + + const cascade = await cascadeKillChildren({ + cfg: params.cfg, + parentChildSessionKey: childKey, + cache: params.cache, + seenChildSessionKeys, + }); + killed += cascade.killed; + labels.push(...cascade.labels); + } + + return { killed, labels }; +} + +export async function killAllControlledSubagentRuns(params: { + cfg: OpenClawConfig; + controller: ResolvedSubagentController; + runs: SubagentRunRecord[]; +}) { + if (params.controller.controlScope !== "children") { + return { + status: "forbidden" as const, + error: "Leaf subagents cannot control other sessions.", + killed: 0, + labels: [], + }; + } + const cache = new Map>(); + const seenChildSessionKeys = new Set(); + const killedLabels: string[] = []; + let killed = 0; + for (const entry of params.runs) { + const childKey = entry.childSessionKey?.trim(); + if (!childKey || seenChildSessionKeys.has(childKey)) { + continue; + } + seenChildSessionKeys.add(childKey); + + if (!entry.endedAt) { + const stopResult = await killSubagentRun({ cfg: params.cfg, entry, cache }); + if (stopResult.killed) { + killed += 1; + killedLabels.push(resolveSubagentLabel(entry)); + } + } + + const cascade = await cascadeKillChildren({ + cfg: params.cfg, + parentChildSessionKey: childKey, + cache, + seenChildSessionKeys, + }); + killed += cascade.killed; + killedLabels.push(...cascade.labels); + } + return { status: "ok" as const, killed, labels: killedLabels }; +} + +export async function killControlledSubagentRun(params: { + cfg: OpenClawConfig; + controller: ResolvedSubagentController; + entry: SubagentRunRecord; +}) { + const ownershipError = ensureControllerOwnsRun({ + controller: params.controller, + entry: params.entry, + }); + if (ownershipError) { + return { + status: "forbidden" as const, + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: ownershipError, + }; + } + if (params.controller.controlScope !== "children") { + return { + status: "forbidden" as const, + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: "Leaf subagents cannot control other sessions.", + }; + } + const killCache = new Map>(); + const stopResult = await killSubagentRun({ + cfg: params.cfg, + entry: params.entry, + cache: killCache, + }); + const seenChildSessionKeys = new Set(); + const targetChildKey = params.entry.childSessionKey?.trim(); + if (targetChildKey) { + seenChildSessionKeys.add(targetChildKey); + } + const cascade = await cascadeKillChildren({ + cfg: params.cfg, + parentChildSessionKey: params.entry.childSessionKey, + cache: killCache, + seenChildSessionKeys, + }); + if (!stopResult.killed && cascade.killed === 0) { + return { + status: "done" as const, + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + label: resolveSubagentLabel(params.entry), + text: `${resolveSubagentLabel(params.entry)} is already finished.`, + }; + } + const cascadeText = + cascade.killed > 0 ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` : ""; + return { + status: "ok" as const, + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + label: resolveSubagentLabel(params.entry), + cascadeKilled: cascade.killed, + cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined, + text: stopResult.killed + ? `killed ${resolveSubagentLabel(params.entry)}${cascadeText}.` + : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(params.entry)}.`, + }; +} + +export async function steerControlledSubagentRun(params: { + cfg: OpenClawConfig; + controller: ResolvedSubagentController; + entry: SubagentRunRecord; + message: string; +}): Promise< + | { + status: "forbidden" | "done" | "rate_limited" | "error"; + runId?: string; + sessionKey: string; + sessionId?: string; + error?: string; + text?: string; + } + | { + status: "accepted"; + runId: string; + sessionKey: string; + sessionId?: string; + mode: "restart"; + label: string; + text: string; + } +> { + const ownershipError = ensureControllerOwnsRun({ + controller: params.controller, + entry: params.entry, + }); + if (ownershipError) { + return { + status: "forbidden", + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: ownershipError, + }; + } + if (params.controller.controlScope !== "children") { + return { + status: "forbidden", + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: "Leaf subagents cannot control other sessions.", + }; + } + if (params.entry.endedAt) { + return { + status: "done", + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + text: `${resolveSubagentLabel(params.entry)} is already finished.`, + }; + } + if (params.controller.callerSessionKey === params.entry.childSessionKey) { + return { + status: "forbidden", + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: "Subagents cannot steer themselves.", + }; + } + + const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`; + if (process.env.VITEST !== "true") { + const now = Date.now(); + const lastSentAt = steerRateLimit.get(rateKey) ?? 0; + if (now - lastSentAt < STEER_RATE_LIMIT_MS) { + return { + status: "rate_limited", + runId: params.entry.runId, + sessionKey: params.entry.childSessionKey, + error: "Steer rate limit exceeded. Wait a moment before sending another steer.", + }; + } + steerRateLimit.set(rateKey, now); + } + + markSubagentRunForSteerRestart(params.entry.runId); + + const targetSession = resolveSessionEntryForKey({ + cfg: params.cfg, + key: params.entry.childSessionKey, + cache: new Map>(), + }); + const sessionId = + typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim() + ? targetSession.entry.sessionId.trim() + : undefined; + + if (sessionId) { + abortEmbeddedPiRun(sessionId); + } + const cleared = clearSessionQueues([params.entry.childSessionKey, sessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents control steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + + try { + await callGateway({ + method: "agent.wait", + params: { + runId: params.entry.runId, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, + }, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, + }); + } catch { + // Continue even if wait fails; steer should still be attempted. + } + + const idempotencyKey = crypto.randomUUID(); + let runId: string = idempotencyKey; + try { + const response = await callGateway<{ runId: string }>({ + method: "agent", + params: { + message: params.message, + sessionKey: params.entry.childSessionKey, + sessionId, + idempotencyKey, + deliver: false, + channel: INTERNAL_MESSAGE_CHANNEL, + lane: AGENT_LANE_SUBAGENT, + timeout: 0, + }, + timeoutMs: 10_000, + }); + if (typeof response?.runId === "string" && response.runId) { + runId = response.runId; + } + } catch (err) { + clearSubagentRunSteerRestart(params.entry.runId); + const error = err instanceof Error ? err.message : String(err); + return { + status: "error", + runId, + sessionKey: params.entry.childSessionKey, + sessionId, + error, + }; + } + + replaceSubagentRunAfterSteer({ + previousRunId: params.entry.runId, + nextRunId: runId, + fallback: params.entry, + runTimeoutSeconds: params.entry.runTimeoutSeconds ?? 0, + }); + + return { + status: "accepted", + runId, + sessionKey: params.entry.childSessionKey, + sessionId, + mode: "restart", + label: resolveSubagentLabel(params.entry), + text: `steered ${resolveSubagentLabel(params.entry)}.`, + }; +} + +export async function sendControlledSubagentMessage(params: { + cfg: OpenClawConfig; + entry: SubagentRunRecord; + message: string; +}) { + const targetSessionKey = params.entry.childSessionKey; + const parsed = parseAgentSessionKey(targetSessionKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); + const store = loadSessionStore(storePath); + const targetSessionEntry = store[targetSessionKey]; + const targetSessionId = + typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() + ? targetSessionEntry.sessionId.trim() + : undefined; + + const idempotencyKey = crypto.randomUUID(); + let runId: string = idempotencyKey; + const response = await callGateway<{ runId: string }>({ + method: "agent", + params: { + message: params.message, + sessionKey: targetSessionKey, + sessionId: targetSessionId, + idempotencyKey, + deliver: false, + channel: INTERNAL_MESSAGE_CHANNEL, + lane: AGENT_LANE_SUBAGENT, + timeout: 0, + }, + timeoutMs: 10_000, + }); + const responseRunId = typeof response?.runId === "string" ? response.runId : undefined; + if (responseRunId) { + runId = responseRunId; + } + + const waitMs = 30_000; + const wait = await callGateway<{ status?: string; error?: string }>({ + method: "agent.wait", + params: { runId, timeoutMs: waitMs }, + timeoutMs: waitMs + 2_000, + }); + if (wait?.status === "timeout") { + return { status: "timeout" as const, runId }; + } + if (wait?.status === "error") { + const waitError = typeof wait.error === "string" ? wait.error : "unknown error"; + return { status: "error" as const, runId, error: waitError }; + } + + const history = await callGateway<{ messages: Array }>({ + method: "chat.history", + params: { sessionKey: targetSessionKey, limit: 50 }, + }); + const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); + const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; + const replyText = last ? extractAssistantText(last) : undefined; + return { status: "ok" as const, runId, replyText }; +} + +export function resolveControlledSubagentTarget( + runs: SubagentRunRecord[], + token: string | undefined, + options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean }, +): SubagentTargetResolution { + return resolveSubagentTargetFromRuns({ + runs, + token, + recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES, + label: (entry) => resolveSubagentLabel(entry), + isActive: options?.isActive, + errors: { + missingTarget: "Missing subagent target.", + invalidIndex: (value) => `Invalid subagent index: ${value}`, + unknownSession: (value) => `Unknown subagent session: ${value}`, + ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`, + ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`, + ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`, + unknownTarget: (value) => `Unknown subagent target: ${value}`, + }, + }); +} diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts index 7c40444d6f1..4ddf23bf2db 100644 --- a/src/agents/subagent-registry-queries.ts +++ b/src/agents/subagent-registry-queries.ts @@ -1,6 +1,10 @@ import type { DeliveryContext } from "../utils/delivery-context.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; +function resolveControllerSessionKey(entry: SubagentRunRecord): string { + return entry.controllerSessionKey?.trim() || entry.requesterSessionKey; +} + export function findRunIdsByChildSessionKeyFromRuns( runs: Map, childSessionKey: string, @@ -51,6 +55,17 @@ export function listRunsForRequesterFromRuns( }); } +export function listRunsForControllerFromRuns( + runs: Map, + controllerSessionKey: string, +): SubagentRunRecord[] { + const key = controllerSessionKey.trim(); + if (!key) { + return []; + } + return [...runs.values()].filter((entry) => resolveControllerSessionKey(entry) === key); +} + function findLatestRunForChildSession( runs: Map, childSessionKey: string, @@ -104,9 +119,9 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns( export function countActiveRunsForSessionFromRuns( runs: Map, - requesterSessionKey: string, + controllerSessionKey: string, ): number { - const key = requesterSessionKey.trim(); + const key = controllerSessionKey.trim(); if (!key) { return 0; } @@ -123,7 +138,7 @@ export function countActiveRunsForSessionFromRuns( let count = 0; for (const entry of runs.values()) { - if (entry.requesterSessionKey !== key) { + if (resolveControllerSessionKey(entry) !== key) { continue; } if (typeof entry.endedAt !== "number") { diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 9ef58933f35..477544bdd3d 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -45,6 +45,7 @@ import { countPendingDescendantRunsExcludingRunFromRuns, countPendingDescendantRunsFromRuns, findRunIdsByChildSessionKeyFromRuns, + listRunsForControllerFromRuns, listDescendantRunsForRequesterFromRuns, listRunsForRequesterFromRuns, resolveRequesterForChildSessionFromRuns, @@ -1146,6 +1147,7 @@ export function replaceSubagentRunAfterSteer(params: { export function registerSubagentRun(params: { runId: string; childSessionKey: string; + controllerSessionKey?: string; requesterSessionKey: string; requesterOrigin?: DeliveryContext; requesterDisplayKey: string; @@ -1173,6 +1175,7 @@ export function registerSubagentRun(params: { subagentRuns.set(params.runId, { runId: params.runId, childSessionKey: params.childSessionKey, + controllerSessionKey: params.controllerSessionKey ?? params.requesterSessionKey, requesterSessionKey: params.requesterSessionKey, requesterOrigin, requesterDisplayKey: params.requesterDisplayKey, @@ -1419,6 +1422,13 @@ export function listSubagentRunsForRequester( return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey, options); } +export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] { + return listRunsForControllerFromRuns( + getSubagentRunsSnapshotForRead(subagentRuns), + controllerSessionKey, + ); +} + export function countActiveRunsForSession(requesterSessionKey: string): number { return countActiveRunsForSessionFromRuns( getSubagentRunsSnapshotForRead(subagentRuns), diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index a153ddbadd7..f5dc56775ae 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -6,6 +6,7 @@ import type { SpawnSubagentMode } from "./subagent-spawn.js"; export type SubagentRunRecord = { runId: string; childSessionKey: string; + controllerSessionKey?: string; requesterSessionKey: string; requesterOrigin?: DeliveryContext; requesterDisplayKey: string; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index f2a63552189..be5dac37f83 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -27,6 +27,7 @@ import { materializeSubagentAttachments, type SubagentAttachmentReceiptFile, } from "./subagent-attachments.js"; +import { resolveSubagentCapabilities } from "./subagent-capabilities.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js"; import { readStringParam } from "./tools/common.js"; @@ -376,6 +377,10 @@ export async function spawnSubagentDirect( } const childDepth = callerDepth + 1; const spawnedByKey = requesterInternalKey; + const childCapabilities = resolveSubagentCapabilities({ + depth: childDepth, + maxSpawnDepth, + }); const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); const resolvedModel = resolveSubagentSpawnModelSelection({ cfg, @@ -414,7 +419,11 @@ export async function spawnSubagentDirect( } }; - const spawnDepthPatchError = await patchChildSession({ spawnDepth: childDepth }); + const spawnDepthPatchError = await patchChildSession({ + spawnDepth: childDepth, + subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role, + subagentControlScope: childCapabilities.controlScope, + }); if (spawnDepthPatchError) { return { status: "error", @@ -643,6 +652,7 @@ export async function spawnSubagentDirect( registerSubagentRun({ runId: childRunId, childSessionKey, + controllerSessionKey: requesterInternalKey, requesterSessionKey: requesterInternalKey, requesterOrigin, requesterDisplayKey, diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index 8735bc8809c..a7eb53c5d46 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -1,56 +1,26 @@ -import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; -import { clearSessionQueues } from "../../auto-reply/reply/queue.js"; -import { - resolveSubagentLabel, - resolveSubagentTargetFromRuns, - sortSubagentRuns, - type SubagentTargetResolution, -} from "../../auto-reply/reply/subagents-utils.js"; import { loadConfig } from "../../config/config.js"; -import type { SessionEntry } from "../../config/sessions.js"; -import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; -import { callGateway } from "../../gateway/call.js"; -import { logVerbose } from "../../globals.js"; -import { - isSubagentSessionKey, - parseAgentSessionKey, - type ParsedAgentSessionKey, -} from "../../routing/session-key.js"; -import { - formatDurationCompact, - formatTokenUsageDisplay, - resolveTotalTokens, - truncateLine, -} from "../../shared/subagents-format.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; -import { AGENT_LANE_SUBAGENT } from "../lanes.js"; -import { abortEmbeddedPiRun } from "../pi-embedded.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { - clearSubagentRunSteerRestart, - countPendingDescendantRuns, - listSubagentRunsForRequester, - markSubagentRunTerminated, - markSubagentRunForSteerRestart, - replaceSubagentRunAfterSteer, - type SubagentRunRecord, -} from "../subagent-registry.js"; + buildSubagentList, + DEFAULT_RECENT_MINUTES, + isActiveSubagentRun, + killAllControlledSubagentRuns, + killControlledSubagentRun, + listControlledSubagentRuns, + MAX_RECENT_MINUTES, + MAX_STEER_MESSAGE_CHARS, + resolveControlledSubagentTarget, + resolveSubagentController, + steerControlledSubagentRun, + createPendingDescendantCounter, +} from "../subagent-control.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; -import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; const SUBAGENT_ACTIONS = ["list", "kill", "steer"] as const; type SubagentAction = (typeof SUBAGENT_ACTIONS)[number]; -const DEFAULT_RECENT_MINUTES = 30; -const MAX_RECENT_MINUTES = 24 * 60; -const MAX_STEER_MESSAGE_CHARS = 4_000; -const STEER_RATE_LIMIT_MS = 2_000; -const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; - -const steerRateLimit = new Map(); - const SubagentsToolSchema = Type.Object({ action: optionalStringEnum(SUBAGENT_ACTIONS), target: Type.Optional(Type.String()), @@ -58,284 +28,6 @@ const SubagentsToolSchema = Type.Object({ recentMinutes: Type.Optional(Type.Number({ minimum: 1 })), }); -type SessionEntryResolution = { - storePath: string; - entry: SessionEntry | undefined; -}; - -type ResolvedRequesterKey = { - requesterSessionKey: string; - callerSessionKey: string; - callerIsSubagent: boolean; -}; - -function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) { - const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0); - if (pendingDescendants > 0) { - const childLabel = pendingDescendants === 1 ? "child" : "children"; - return `active (waiting on ${pendingDescendants} ${childLabel})`; - } - if (!entry.endedAt) { - return "running"; - } - const status = entry.outcome?.status ?? "done"; - if (status === "ok") { - return "done"; - } - if (status === "error") { - return "failed"; - } - return status; -} - -function resolveModelRef(entry?: SessionEntry) { - const model = typeof entry?.model === "string" ? entry.model.trim() : ""; - const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; - if (model.includes("/")) { - return model; - } - if (model && provider) { - return `${provider}/${model}`; - } - if (model) { - return model; - } - if (provider) { - return provider; - } - // Fall back to override fields which are populated at spawn time, - // before the first run completes and writes model/modelProvider. - const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; - const overrideProvider = - typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; - if (overrideModel.includes("/")) { - return overrideModel; - } - if (overrideModel && overrideProvider) { - return `${overrideProvider}/${overrideModel}`; - } - if (overrideModel) { - return overrideModel; - } - return overrideProvider || undefined; -} - -function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { - const modelRef = resolveModelRef(entry) || fallbackModel || undefined; - if (!modelRef) { - return "model n/a"; - } - const slash = modelRef.lastIndexOf("/"); - if (slash >= 0 && slash < modelRef.length - 1) { - return modelRef.slice(slash + 1); - } - return modelRef; -} - -function resolveSubagentTarget( - runs: SubagentRunRecord[], - token: string | undefined, - options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean }, -): SubagentTargetResolution { - return resolveSubagentTargetFromRuns({ - runs, - token, - recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES, - label: (entry) => resolveSubagentLabel(entry), - isActive: options?.isActive, - errors: { - missingTarget: "Missing subagent target.", - invalidIndex: (value) => `Invalid subagent index: ${value}`, - unknownSession: (value) => `Unknown subagent session: ${value}`, - ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`, - ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`, - ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`, - unknownTarget: (value) => `Unknown subagent target: ${value}`, - }, - }); -} - -function resolveStorePathForKey( - cfg: ReturnType, - key: string, - parsed?: ParsedAgentSessionKey | null, -) { - return resolveStorePath(cfg.session?.store, { - agentId: parsed?.agentId, - }); -} - -function resolveSessionEntryForKey(params: { - cfg: ReturnType; - key: string; - cache: Map>; -}): SessionEntryResolution { - const parsed = parseAgentSessionKey(params.key); - const storePath = resolveStorePathForKey(params.cfg, params.key, parsed); - let store = params.cache.get(storePath); - if (!store) { - store = loadSessionStore(storePath); - params.cache.set(storePath, store); - } - return { - storePath, - entry: store[params.key], - }; -} - -function resolveRequesterKey(params: { - cfg: ReturnType; - agentSessionKey?: string; -}): ResolvedRequesterKey { - const { mainKey, alias } = resolveMainSessionAlias(params.cfg); - const callerRaw = params.agentSessionKey?.trim() || alias; - const callerSessionKey = resolveInternalSessionKey({ - key: callerRaw, - alias, - mainKey, - }); - if (!isSubagentSessionKey(callerSessionKey)) { - return { - requesterSessionKey: callerSessionKey, - callerSessionKey, - callerIsSubagent: false, - }; - } - - return { - // Subagents can only control runs spawned from their own session key. - // Announce routing still uses SubagentRunRecord.requesterSessionKey elsewhere. - requesterSessionKey: callerSessionKey, - callerSessionKey, - callerIsSubagent: true, - }; -} - -function ensureSubagentControlsOwnDescendants(params: { - requester: ResolvedRequesterKey; - entry: SubagentRunRecord; -}) { - if (!params.requester.callerIsSubagent) { - return undefined; - } - if (params.entry.requesterSessionKey === params.requester.callerSessionKey) { - return undefined; - } - return "Subagents can only control runs spawned from their own session."; -} - -async function killSubagentRun(params: { - cfg: ReturnType; - entry: SubagentRunRecord; - cache: Map>; -}): Promise<{ killed: boolean; sessionId?: string }> { - if (params.entry.endedAt) { - return { killed: false }; - } - const childSessionKey = params.entry.childSessionKey; - const resolved = resolveSessionEntryForKey({ - cfg: params.cfg, - key: childSessionKey, - cache: params.cache, - }); - const sessionId = resolved.entry?.sessionId; - const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false; - const cleared = clearSessionQueues([childSessionKey, sessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents tool kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } - if (resolved.entry) { - await updateSessionStore(resolved.storePath, (store) => { - const current = store[childSessionKey]; - if (!current) { - return; - } - current.abortedLastRun = true; - current.updatedAt = Date.now(); - store[childSessionKey] = current; - }); - } - const marked = markSubagentRunTerminated({ - runId: params.entry.runId, - childSessionKey, - reason: "killed", - }); - const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0; - return { killed, sessionId }; -} - -/** - * Recursively kill all descendant subagent runs spawned by a given parent session key. - * This ensures that when a subagent is killed, all of its children (and their children) are also killed. - */ -async function cascadeKillChildren(params: { - cfg: ReturnType; - parentChildSessionKey: string; - cache: Map>; - seenChildSessionKeys?: Set; -}): Promise<{ killed: number; labels: string[] }> { - const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey); - const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set(); - let killed = 0; - const labels: string[] = []; - - for (const run of childRuns) { - const childKey = run.childSessionKey?.trim(); - if (!childKey || seenChildSessionKeys.has(childKey)) { - continue; - } - seenChildSessionKeys.add(childKey); - - if (!run.endedAt) { - const stopResult = await killSubagentRun({ - cfg: params.cfg, - entry: run, - cache: params.cache, - }); - if (stopResult.killed) { - killed += 1; - labels.push(resolveSubagentLabel(run)); - } - } - - // Recurse for grandchildren even if this parent already ended. - const cascade = await cascadeKillChildren({ - cfg: params.cfg, - parentChildSessionKey: childKey, - cache: params.cache, - seenChildSessionKeys, - }); - killed += cascade.killed; - labels.push(...cascade.labels); - } - - return { killed, labels }; -} - -function buildListText(params: { - active: Array<{ line: string }>; - recent: Array<{ line: string }>; - recentMinutes: number; -}) { - const lines: string[] = []; - lines.push("active subagents:"); - if (params.active.length === 0) { - lines.push("(none)"); - } else { - lines.push(...params.active.map((entry) => entry.line)); - } - lines.push(""); - lines.push(`recent (last ${params.recentMinutes}m):`); - if (params.recent.length === 0) { - lines.push("(none)"); - } else { - lines.push(...params.recent.map((entry) => entry.line)); - } - return lines.join("\n"); -} - export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAgentTool { return { label: "Subagents", @@ -347,139 +39,69 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge const params = args as Record; const action = (readStringParam(params, "action") ?? "list") as SubagentAction; const cfg = loadConfig(); - const requester = resolveRequesterKey({ + const controller = resolveSubagentController({ cfg, agentSessionKey: opts?.agentSessionKey, }); - const runs = sortSubagentRuns(listSubagentRunsForRequester(requester.requesterSessionKey)); + const runs = listControlledSubagentRuns(controller.controllerSessionKey); const recentMinutesRaw = readNumberParam(params, "recentMinutes"); const recentMinutes = recentMinutesRaw ? Math.max(1, Math.min(MAX_RECENT_MINUTES, Math.floor(recentMinutesRaw))) : DEFAULT_RECENT_MINUTES; - const pendingDescendantCache = new Map(); - const pendingDescendantCount = (sessionKey: string) => { - if (pendingDescendantCache.has(sessionKey)) { - return pendingDescendantCache.get(sessionKey) ?? 0; - } - const pending = Math.max(0, countPendingDescendantRuns(sessionKey)); - pendingDescendantCache.set(sessionKey, pending); - return pending; - }; - const isActiveRun = (entry: SubagentRunRecord) => - !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0; + const pendingDescendantCount = createPendingDescendantCounter(); + const isActive = (entry: (typeof runs)[number]) => + isActiveSubagentRun(entry, pendingDescendantCount); if (action === "list") { - const now = Date.now(); - const recentCutoff = now - recentMinutes * 60_000; - const cache = new Map>(); - - let index = 1; - const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => { - const sessionEntry = resolveSessionEntryForKey({ - cfg, - key: entry.childSessionKey, - cache, - }).entry; - const totalTokens = resolveTotalTokens(sessionEntry); - const usageText = formatTokenUsageDisplay(sessionEntry); - const pendingDescendants = pendingDescendantCount(entry.childSessionKey); - const status = resolveRunStatus(entry, { - pendingDescendants, - }); - const runtime = formatDurationCompact(runtimeMs); - const label = truncateLine(resolveSubagentLabel(entry), 48); - const task = truncateLine(entry.task.trim(), 72); - const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; - const baseView = { - index, - runId: entry.runId, - sessionKey: entry.childSessionKey, - label, - task, - status, - pendingDescendants, - runtime, - runtimeMs, - model: resolveModelRef(sessionEntry) || entry.model, - totalTokens, - startedAt: entry.startedAt, - }; - index += 1; - return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView }; - }; - const active = runs - .filter((entry) => isActiveRun(entry)) - .map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt))); - const recent = runs - .filter( - (entry) => - !isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff, - ) - .map((entry) => - buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)), - ); - - const text = buildListText({ active, recent, recentMinutes }); + const list = buildSubagentList({ + cfg, + runs, + recentMinutes, + }); return jsonResult({ status: "ok", action: "list", - requesterSessionKey: requester.requesterSessionKey, - callerSessionKey: requester.callerSessionKey, - callerIsSubagent: requester.callerIsSubagent, - total: runs.length, - active: active.map((entry) => entry.view), - recent: recent.map((entry) => entry.view), - text, + requesterSessionKey: controller.controllerSessionKey, + callerSessionKey: controller.callerSessionKey, + callerIsSubagent: controller.callerIsSubagent, + total: list.total, + active: list.active.map(({ line: _line, ...view }) => view), + recent: list.recent.map(({ line: _line, ...view }) => view), + text: list.text, }); } if (action === "kill") { const target = readStringParam(params, "target", { required: true }); if (target === "all" || target === "*") { - const cache = new Map>(); - const seenChildSessionKeys = new Set(); - const killedLabels: string[] = []; - let killed = 0; - for (const entry of runs) { - const childKey = entry.childSessionKey?.trim(); - if (!childKey || seenChildSessionKeys.has(childKey)) { - continue; - } - seenChildSessionKeys.add(childKey); - - if (!entry.endedAt) { - const stopResult = await killSubagentRun({ cfg, entry, cache }); - if (stopResult.killed) { - killed += 1; - killedLabels.push(resolveSubagentLabel(entry)); - } - } - - // Traverse descendants even when the direct run is already finished. - const cascade = await cascadeKillChildren({ - cfg, - parentChildSessionKey: childKey, - cache, - seenChildSessionKeys, + const result = await killAllControlledSubagentRuns({ + cfg, + controller, + runs, + }); + if (result.status === "forbidden") { + return jsonResult({ + status: "forbidden", + action: "kill", + target: "all", + error: result.error, }); - killed += cascade.killed; - killedLabels.push(...cascade.labels); } return jsonResult({ status: "ok", action: "kill", target: "all", - killed, - labels: killedLabels, + killed: result.killed, + labels: result.labels, text: - killed > 0 - ? `killed ${killed} subagent${killed === 1 ? "" : "s"}.` + result.killed > 0 + ? `killed ${result.killed} subagent${result.killed === 1 ? "" : "s"}.` : "no running subagents to kill.", }); } - const resolved = resolveSubagentTarget(runs, target, { + const resolved = resolveControlledSubagentTarget(runs, target, { recentMinutes, - isActive: isActiveRun, + isActive, }); if (!resolved.entry) { return jsonResult({ @@ -489,66 +111,25 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge error: resolved.error ?? "Unknown subagent target.", }); } - const ownershipError = ensureSubagentControlsOwnDescendants({ - requester, + const result = await killControlledSubagentRun({ + cfg, + controller, entry: resolved.entry, }); - if (ownershipError) { - return jsonResult({ - status: "forbidden", - action: "kill", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - error: ownershipError, - }); - } - const killCache = new Map>(); - const stopResult = await killSubagentRun({ - cfg, - entry: resolved.entry, - cache: killCache, - }); - const seenChildSessionKeys = new Set(); - const targetChildKey = resolved.entry.childSessionKey?.trim(); - if (targetChildKey) { - seenChildSessionKeys.add(targetChildKey); - } - // Traverse descendants even when the selected run is already finished. - const cascade = await cascadeKillChildren({ - cfg, - parentChildSessionKey: resolved.entry.childSessionKey, - cache: killCache, - seenChildSessionKeys, - }); - if (!stopResult.killed && cascade.killed === 0) { - return jsonResult({ - status: "done", - action: "kill", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - text: `${resolveSubagentLabel(resolved.entry)} is already finished.`, - }); - } - const cascadeText = - cascade.killed > 0 - ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` - : ""; return jsonResult({ - status: "ok", + status: result.status, action: "kill", target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - label: resolveSubagentLabel(resolved.entry), - cascadeKilled: cascade.killed, - cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined, - text: stopResult.killed - ? `killed ${resolveSubagentLabel(resolved.entry)}${cascadeText}.` - : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(resolved.entry)}.`, + runId: result.runId, + sessionKey: result.sessionKey, + label: result.label, + cascadeKilled: "cascadeKilled" in result ? result.cascadeKilled : undefined, + cascadeLabels: "cascadeLabels" in result ? result.cascadeLabels : undefined, + error: "error" in result ? result.error : undefined, + text: result.text, }); } + if (action === "steer") { const target = readStringParam(params, "target", { required: true }); const message = readStringParam(params, "message", { required: true }); @@ -560,9 +141,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge error: `Message too long (${message.length} chars, max ${MAX_STEER_MESSAGE_CHARS}).`, }); } - const resolved = resolveSubagentTarget(runs, target, { + const resolved = resolveControlledSubagentTarget(runs, target, { recentMinutes, - isActive: isActiveRun, + isActive, }); if (!resolved.entry) { return jsonResult({ @@ -572,154 +153,26 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge error: resolved.error ?? "Unknown subagent target.", }); } - const ownershipError = ensureSubagentControlsOwnDescendants({ - requester, - entry: resolved.entry, - }); - if (ownershipError) { - return jsonResult({ - status: "forbidden", - action: "steer", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - error: ownershipError, - }); - } - if (resolved.entry.endedAt) { - return jsonResult({ - status: "done", - action: "steer", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - text: `${resolveSubagentLabel(resolved.entry)} is already finished.`, - }); - } - if ( - requester.callerIsSubagent && - requester.callerSessionKey === resolved.entry.childSessionKey - ) { - return jsonResult({ - status: "forbidden", - action: "steer", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - error: "Subagents cannot steer themselves.", - }); - } - - const rateKey = `${requester.callerSessionKey}:${resolved.entry.childSessionKey}`; - const now = Date.now(); - const lastSentAt = steerRateLimit.get(rateKey) ?? 0; - if (now - lastSentAt < STEER_RATE_LIMIT_MS) { - return jsonResult({ - status: "rate_limited", - action: "steer", - target, - runId: resolved.entry.runId, - sessionKey: resolved.entry.childSessionKey, - error: "Steer rate limit exceeded. Wait a moment before sending another steer.", - }); - } - steerRateLimit.set(rateKey, now); - - // Suppress announce for the interrupted run before aborting so we don't - // emit stale pre-steer findings if the run exits immediately. - markSubagentRunForSteerRestart(resolved.entry.runId); - - const targetSession = resolveSessionEntryForKey({ + const result = await steerControlledSubagentRun({ cfg, - key: resolved.entry.childSessionKey, - cache: new Map>(), + controller, + entry: resolved.entry, + message, }); - const sessionId = - typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim() - ? targetSession.entry.sessionId.trim() - : undefined; - - // Interrupt current work first so steer takes precedence immediately. - if (sessionId) { - abortEmbeddedPiRun(sessionId); - } - const cleared = clearSessionQueues([resolved.entry.childSessionKey, sessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents tool steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } - - // Best effort: wait for the interrupted run to settle so the steer - // message appends onto the existing conversation context. - try { - await callGateway({ - method: "agent.wait", - params: { - runId: resolved.entry.runId, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, - }, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, - }); - } catch { - // Continue even if wait fails; steer should still be attempted. - } - - const idempotencyKey = crypto.randomUUID(); - let runId: string = idempotencyKey; - try { - const response = await callGateway<{ runId: string }>({ - method: "agent", - params: { - message, - sessionKey: resolved.entry.childSessionKey, - sessionId, - idempotencyKey, - deliver: false, - channel: INTERNAL_MESSAGE_CHANNEL, - lane: AGENT_LANE_SUBAGENT, - timeout: 0, - }, - timeoutMs: 10_000, - }); - if (typeof response?.runId === "string" && response.runId) { - runId = response.runId; - } - } catch (err) { - // Replacement launch failed; restore normal announce behavior for the - // original run so completion is not silently suppressed. - clearSubagentRunSteerRestart(resolved.entry.runId); - const error = err instanceof Error ? err.message : String(err); - return jsonResult({ - status: "error", - action: "steer", - target, - runId, - sessionKey: resolved.entry.childSessionKey, - sessionId, - error, - }); - } - - replaceSubagentRunAfterSteer({ - previousRunId: resolved.entry.runId, - nextRunId: runId, - fallback: resolved.entry, - runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0, - }); - return jsonResult({ - status: "accepted", + status: result.status, action: "steer", target, - runId, - sessionKey: resolved.entry.childSessionKey, - sessionId, - mode: "restart", - label: resolveSubagentLabel(resolved.entry), - text: `steered ${resolveSubagentLabel(resolved.entry)}.`, + runId: result.runId, + sessionKey: result.sessionKey, + sessionId: result.sessionId, + mode: "mode" in result ? result.mode : undefined, + label: "label" in result ? result.label : undefined, + error: "error" in result ? result.error : undefined, + text: result.text, }); } + return jsonResult({ status: "error", error: "Unsupported action.", diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index d0f97f04fa8..58ea5e59fa6 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -2,7 +2,7 @@ import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; import { - listSubagentRunsForRequester, + listSubagentRunsForController, markSubagentRunTerminated, } from "../../agents/subagent-registry.js"; import { @@ -222,7 +222,7 @@ export function stopSubagentsForRequester(params: { if (!requesterKey) { return { stopped: 0 }; } - const runs = listSubagentRunsForRequester(requesterKey); + const runs = listSubagentRunsForController(requesterKey); if (runs.length === 0) { return { stopped: 0 }; } diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 906ad93eb48..cffc6e003a8 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -1,4 +1,4 @@ -import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; +import { listSubagentRunsForController } from "../../agents/subagent-registry.js"; import { logVerbose } from "../../globals.js"; import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js"; import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js"; @@ -61,7 +61,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo params, handledPrefix, requesterKey, - runs: listSubagentRunsForRequester(requesterKey), + runs: listSubagentRunsForController(requesterKey), restTokens, }; diff --git a/src/auto-reply/reply/commands-subagents/action-kill.ts b/src/auto-reply/reply/commands-subagents/action-kill.ts index cb91b4432f7..597e3b4c9c4 100644 --- a/src/auto-reply/reply/commands-subagents/action-kill.ts +++ b/src/auto-reply/reply/commands-subagents/action-kill.ts @@ -1,19 +1,13 @@ -import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js"; -import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js"; import { - loadSessionStore, - resolveStorePath, - updateSessionStore, -} from "../../../config/sessions.js"; -import { logVerbose } from "../../../globals.js"; -import { stopSubagentsForRequester } from "../abort.js"; + killAllControlledSubagentRuns, + killControlledSubagentRun, +} from "../../../agents/subagent-control.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { clearSessionQueues } from "../queue.js"; import { formatRunLabel } from "../subagents-utils.js"; import { type SubagentsCommandContext, COMMAND, - loadSubagentSessionEntry, + resolveCommandSubagentController, resolveSubagentEntryForToken, stopWithText, } from "./shared.js"; @@ -30,10 +24,18 @@ export async function handleSubagentsKillAction( } if (target === "all" || target === "*") { - stopSubagentsForRequester({ + const controller = resolveCommandSubagentController(params, requesterKey); + const result = await killAllControlledSubagentRuns({ cfg: params.cfg, - requesterSessionKey: requesterKey, + controller, + runs, }); + if (result.status === "forbidden") { + return stopWithText(`⚠️ ${result.error}`); + } + if (result.killed > 0) { + return { shouldContinue: false }; + } return { shouldContinue: false }; } @@ -45,42 +47,17 @@ export async function handleSubagentsKillAction( return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); } - const childKey = targetResolution.entry.childSessionKey; - const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, { - loadSessionStore, - resolveStorePath, - }); - const sessionId = entry?.sessionId; - if (sessionId) { - abortEmbeddedPiRun(sessionId); - } - - const cleared = clearSessionQueues([childKey, sessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } - - if (entry) { - entry.abortedLastRun = true; - entry.updatedAt = Date.now(); - store[childKey] = entry; - await updateSessionStore(storePath, (nextStore) => { - nextStore[childKey] = entry; - }); - } - - markSubagentRunTerminated({ - runId: targetResolution.entry.runId, - childSessionKey: childKey, - reason: "killed", - }); - - stopSubagentsForRequester({ + const controller = resolveCommandSubagentController(params, requesterKey); + const result = await killControlledSubagentRun({ cfg: params.cfg, - requesterSessionKey: childKey, + controller, + entry: targetResolution.entry, }); - + if (result.status === "forbidden") { + return stopWithText(`⚠️ ${result.error}`); + } + if (result.status === "done") { + return stopWithText(result.text); + } return { shouldContinue: false }; } diff --git a/src/auto-reply/reply/commands-subagents/action-list.ts b/src/auto-reply/reply/commands-subagents/action-list.ts index 026874e22aa..e777c498d5f 100644 --- a/src/auto-reply/reply/commands-subagents/action-list.ts +++ b/src/auto-reply/reply/commands-subagents/action-list.ts @@ -1,79 +1,26 @@ -import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js"; -import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; +import { buildSubagentList } from "../../../agents/subagent-control.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { sortSubagentRuns } from "../subagents-utils.js"; -import { - type SessionStoreCache, - type SubagentsCommandContext, - RECENT_WINDOW_MINUTES, - formatSubagentListLine, - loadSubagentSessionEntry, - stopWithText, -} from "./shared.js"; +import { type SubagentsCommandContext, RECENT_WINDOW_MINUTES, stopWithText } from "./shared.js"; export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult { const { params, runs } = ctx; - const sorted = sortSubagentRuns(runs); - const now = Date.now(); - const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000; - const storeCache: SessionStoreCache = new Map(); - const pendingDescendantCache = new Map(); - const pendingDescendantCount = (sessionKey: string) => { - if (pendingDescendantCache.has(sessionKey)) { - return pendingDescendantCache.get(sessionKey) ?? 0; - } - const pending = Math.max(0, countPendingDescendantRuns(sessionKey)); - pendingDescendantCache.set(sessionKey, pending); - return pending; - }; - const isActiveRun = (entry: (typeof runs)[number]) => - !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0; - - let index = 1; - - const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) => - entries.map((entry) => { - const { entry: sessionEntry } = loadSubagentSessionEntry( - params, - entry.childSessionKey, - { - loadSessionStore, - resolveStorePath, - }, - storeCache, - ); - const line = formatSubagentListLine({ - entry, - index, - runtimeMs: runtimeMs(entry), - sessionEntry, - pendingDescendants: pendingDescendantCount(entry.childSessionKey), - }); - index += 1; - return line; - }); - - const activeEntries = sorted.filter((entry) => isActiveRun(entry)); - const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt)); - const recentEntries = sorted.filter( - (entry) => !isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff, - ); - const recentLines = mapRuns( - recentEntries, - (entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), - ); - + const list = buildSubagentList({ + cfg: params.cfg, + runs, + recentMinutes: RECENT_WINDOW_MINUTES, + taskMaxChars: 110, + }); const lines = ["active subagents:", "-----"]; - if (activeLines.length === 0) { + if (list.active.length === 0) { lines.push("(none)"); } else { - lines.push(activeLines.join("\n")); + lines.push(list.active.map((entry) => entry.line).join("\n")); } lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----"); - if (recentLines.length === 0) { + if (list.recent.length === 0) { lines.push("(none)"); } else { - lines.push(recentLines.join("\n")); + lines.push(list.recent.map((entry) => entry.line).join("\n")); } return stopWithText(lines.join("\n")); diff --git a/src/auto-reply/reply/commands-subagents/action-send.ts b/src/auto-reply/reply/commands-subagents/action-send.ts index d8b752571c0..3e764e2a6bb 100644 --- a/src/auto-reply/reply/commands-subagents/action-send.ts +++ b/src/auto-reply/reply/commands-subagents/action-send.ts @@ -1,27 +1,15 @@ -import crypto from "node:crypto"; -import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js"; -import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js"; import { - clearSubagentRunSteerRestart, - replaceSubagentRunAfterSteer, - markSubagentRunForSteerRestart, -} from "../../../agents/subagent-registry.js"; -import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; -import { callGateway } from "../../../gateway/call.js"; -import { logVerbose } from "../../../globals.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js"; + sendControlledSubagentMessage, + steerControlledSubagentRun, +} from "../../../agents/subagent-control.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { clearSessionQueues } from "../queue.js"; import { formatRunLabel } from "../subagents-utils.js"; import { type SubagentsCommandContext, COMMAND, - STEER_ABORT_SETTLE_TIMEOUT_MS, - extractAssistantText, - loadSubagentSessionEntry, + resolveCommandSubagentController, resolveSubagentEntryForToken, stopWithText, - stripToolMessages, } from "./shared.js"; export async function handleSubagentsSendAction( @@ -49,111 +37,41 @@ export async function handleSubagentsSendAction( return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); } - const { entry: targetSessionEntry } = loadSubagentSessionEntry( - params, - targetResolution.entry.childSessionKey, - { - loadSessionStore, - resolveStorePath, - }, - ); - const targetSessionId = - typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() - ? targetSessionEntry.sessionId.trim() - : undefined; - if (steerRequested) { - markSubagentRunForSteerRestart(targetResolution.entry.runId); - - if (targetSessionId) { - abortEmbeddedPiRun(targetSessionId); - } - - const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + const controller = resolveCommandSubagentController(params, ctx.requesterKey); + const result = await steerControlledSubagentRun({ + cfg: params.cfg, + controller, + entry: targetResolution.entry, + message, + }); + if (result.status === "accepted") { + return stopWithText( + `steered ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`, ); } - - try { - await callGateway({ - method: "agent.wait", - params: { - runId: targetResolution.entry.runId, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, - }, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, - }); - } catch { - // Continue even if wait fails; steer should still be attempted. + if (result.status === "done" && result.text) { + return stopWithText(result.text); } + if (result.status === "error") { + return stopWithText(`send failed: ${result.error ?? "error"}`); + } + return stopWithText(`⚠️ ${result.error ?? "send failed"}`); } - const idempotencyKey = crypto.randomUUID(); - let runId: string = idempotencyKey; - try { - const response = await callGateway<{ runId: string }>({ - method: "agent", - params: { - message, - sessionKey: targetResolution.entry.childSessionKey, - sessionId: targetSessionId, - idempotencyKey, - deliver: false, - channel: INTERNAL_MESSAGE_CHANNEL, - lane: AGENT_LANE_SUBAGENT, - timeout: 0, - }, - timeoutMs: 10_000, - }); - const responseRunId = typeof response?.runId === "string" ? response.runId : undefined; - if (responseRunId) { - runId = responseRunId; - } - } catch (err) { - if (steerRequested) { - clearSubagentRunSteerRestart(targetResolution.entry.runId); - } - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return stopWithText(`send failed: ${messageText}`); - } - - if (steerRequested) { - replaceSubagentRunAfterSteer({ - previousRunId: targetResolution.entry.runId, - nextRunId: runId, - fallback: targetResolution.entry, - runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0, - }); - return stopWithText( - `steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`, - ); - } - - const waitMs = 30_000; - const wait = await callGateway<{ status?: string; error?: string }>({ - method: "agent.wait", - params: { runId, timeoutMs: waitMs }, - timeoutMs: waitMs + 2000, + const result = await sendControlledSubagentMessage({ + cfg: params.cfg, + entry: targetResolution.entry, + message, }); - if (wait?.status === "timeout") { - return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`); + if (result.status === "timeout") { + return stopWithText(`⏳ Subagent still running (run ${result.runId.slice(0, 8)}).`); } - if (wait?.status === "error") { - const waitError = typeof wait.error === "string" ? wait.error : "unknown error"; - return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`); + if (result.status === "error") { + return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`); } - - const history = await callGateway<{ messages: Array }>({ - method: "chat.history", - params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 }, - }); - const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); - const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; - const replyText = last ? extractAssistantText(last) : undefined; return stopWithText( - replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`, + result.replyText ?? + `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`, ); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index ec96437e645..bb923b52e46 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -1,3 +1,5 @@ +import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js"; +import type { ResolvedSubagentController } from "../../../agents/subagent-control.js"; import { countPendingDescendantRuns, type SubagentRunRecord, @@ -18,6 +20,7 @@ import { parseDiscordTarget } from "../../../discord/targets.js"; import { callGateway } from "../../../gateway/call.js"; import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { looksLikeSessionId } from "../../../sessions/session-id.js"; import { extractTextFromChatContent } from "../../../shared/chat-content.js"; import { @@ -247,6 +250,29 @@ export function resolveRequesterSessionKey( return resolveInternalSessionKey({ key: raw, alias, mainKey }); } +export function resolveCommandSubagentController( + params: SubagentsCommandParams, + requesterKey: string, +): ResolvedSubagentController { + if (!isSubagentSessionKey(requesterKey)) { + return { + controllerSessionKey: requesterKey, + callerSessionKey: requesterKey, + callerIsSubagent: false, + controlScope: "children", + }; + } + const capabilities = resolveStoredSubagentCapabilities(requesterKey, { + cfg: params.cfg, + }); + return { + controllerSessionKey: requesterKey, + callerSessionKey: requesterKey, + callerIsSubagent: true, + controlScope: capabilities.controlScope, + }; +} + export function resolveHandledPrefix(normalized: string): string | null { return normalized.startsWith(COMMAND) ? COMMAND diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938..3f0ed6d531c 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> ->(); +const probeGateway = + vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> + >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 81d67d13011..817f9efc3d8 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -82,6 +82,10 @@ export type SessionEntry = { forkedFromParent?: boolean; /** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */ spawnDepth?: number; + /** Explicit role assigned at spawn time for subagent tool policy/control decisions. */ + subagentRole?: "orchestrator" | "leaf"; + /** Explicit control scope assigned at spawn time for subagent control decisions. */ + subagentControlScope?: "children" | "none"; systemSent?: boolean; abortedLastRun?: boolean; /** diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 72beff4c667..83f09e8ecba 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -72,6 +72,12 @@ export const SessionsPatchParamsSchema = Type.Object( model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), + subagentRole: Type.Optional( + Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]), + ), + subagentControlScope: Type.Optional( + Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]), + ), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index b4e5ce6e06e..1bf79ba4edf 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -67,6 +67,22 @@ function supportsSpawnLineage(storeKey: string): boolean { return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); } +function normalizeSubagentRole(raw: string): "orchestrator" | "leaf" | undefined { + const normalized = raw.trim().toLowerCase(); + if (normalized === "orchestrator" || normalized === "leaf") { + return normalized; + } + return undefined; +} + +function normalizeSubagentControlScope(raw: string): "children" | "none" | undefined { + const normalized = raw.trim().toLowerCase(); + if (normalized === "children" || normalized === "none") { + return normalized; + } + return undefined; +} + export async function applySessionsPatchToStore(params: { cfg: OpenClawConfig; store: Record; @@ -134,6 +150,48 @@ export async function applySessionsPatchToStore(params: { } } + if ("subagentRole" in patch) { + const raw = patch.subagentRole; + if (raw === null) { + if (existing?.subagentRole) { + return invalid("subagentRole cannot be cleared once set"); + } + } else if (raw !== undefined) { + if (!supportsSpawnLineage(storeKey)) { + return invalid("subagentRole is only supported for subagent:* or acp:* sessions"); + } + const normalized = normalizeSubagentRole(String(raw)); + if (!normalized) { + return invalid('invalid subagentRole (use "orchestrator" or "leaf")'); + } + if (existing?.subagentRole && existing.subagentRole !== normalized) { + return invalid("subagentRole cannot be changed once set"); + } + next.subagentRole = normalized; + } + } + + if ("subagentControlScope" in patch) { + const raw = patch.subagentControlScope; + if (raw === null) { + if (existing?.subagentControlScope) { + return invalid("subagentControlScope cannot be cleared once set"); + } + } else if (raw !== undefined) { + if (!supportsSpawnLineage(storeKey)) { + return invalid("subagentControlScope is only supported for subagent:* or acp:* sessions"); + } + const normalized = normalizeSubagentControlScope(String(raw)); + if (!normalized) { + return invalid('invalid subagentControlScope (use "children" or "none")'); + } + if (existing?.subagentControlScope && existing.subagentControlScope !== normalized) { + return invalid("subagentControlScope cannot be changed once set"); + } + next.subagentControlScope = normalized; + } + } + if ("label" in patch) { const raw = patch.label; if (raw === null) { diff --git a/test/setup.ts b/test/setup.ts index 03b46c2d75b..f232e5fc2d0 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,5 +1,11 @@ import { afterAll, afterEach, beforeAll, vi } from "vitest"; +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => undefined, + getOAuthProviders: () => [], + loginOpenAICodex: vi.fn(), +})); + // Ensure Vitest environment is properly set process.env.VITEST = "true"; // Config validation walks plugin manifests; keep an aggressive cache in tests to avoid