Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447)

* Agents: add subagent orchestration controls

* Agents: add subagent orchestration controls (WIP uncommitted changes)

* feat(subagents): add depth-based spawn gating for sub-sub-agents

* feat(subagents): tool policy, registry, and announce chain for nested agents

* feat(subagents): system prompt, docs, changelog for nested sub-agents

* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback

Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.

Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.

Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.

* fix(subagents): track spawn depth in session store and fix announce routing for nested agents

* Fix compaction status tracking and dedupe overflow compaction triggers

* fix(subagents): enforce depth block via session store and implement cascade kill

* fix: inject group chat context into system prompt

* fix(subagents): always write model to session store at spawn time

* Preserve spawnDepth when agent handler rewrites session entry

* fix(subagents): suppress announce on steer-restart

* fix(subagents): fallback spawned session model to runtime default

* fix(subagents): enforce spawn depth when caller key resolves by sessionId

* feat(subagents): implement active-first ordering for numeric targets and enhance task display

- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.

* fix(subagents): show model for active runs via run record fallback

When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.

Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.

Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay

* feat(chat): implement session key resolution and reset on sidebar navigation

- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.

* fix: subagent timeout=0 passthrough and fallback prompt duplication

Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
  is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
  0 → MAX_SAFE_TIMEOUT_MS)

Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
  message instead of the full original prompt since the session file already
  contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)

* feat(subagents): truncate long task descriptions in subagents command output

- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.

* refactor(subagents): update subagent registry path resolution and improve command output formatting

- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.

* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted

The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.

undefined flowed through the chain as:
  sessions_spawn → timeout: undefined (since undefined != null is false)
  → gateway agent handler → agentCommand opts.timeout: undefined
  → resolveAgentTimeoutMs({ overrideSeconds: undefined })
  → DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)

This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.

Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.

* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)

* fix: thread timeout override through getReplyFromConfig dispatch path

getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).

This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.

* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling

- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.

* feat(tests): add unit tests for steer failure behavior in openclaw-tools

- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.

* fix(subagents): replace stop command with kill in slash commands and documentation

- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.

* feat(tests): add unit tests for readLatestAssistantReply function

- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.

* feat(tests): enhance subagent kill-all cascade tests and announce formatting

- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.

* refactor(subagent): update announce formatting and remove unused constants

- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.

* feat(tests): enhance billing error handling in user-facing text

- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.

* feat(subagent): enhance workflow guidance and auto-announcement clarity

- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.

* fix(cron): avoid announcing interim subagent spawn acks

* chore: clean post-rebase imports

* fix(cron): fall back to child replies when parent stays interim

* fix(subagents): make active-run guidance advisory

* fix(subagents): update announce flow to handle active descendants and enhance test coverage

- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.

* fix(subagents): enhance announce flow and formatting for user updates

- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.

* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)

* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)

* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)

* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
This commit is contained in:
Tyler Yust
2026-02-14 22:03:45 -08:00
committed by GitHub
parent c46f395bb9
commit b8f66c260d
86 changed files with 5700 additions and 821 deletions

View File

@@ -0,0 +1,56 @@
import { afterEach, expect, test } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool } from "./bash-tools.exec.js";
import { createProcessTool } from "./bash-tools.process.js";
afterEach(() => {
resetProcessRegistryForTests();
});
const sleepAndEcho =
process.platform === "win32"
? "Start-Sleep -Milliseconds 300; Write-Output done"
: "sleep 0.3; echo done";
test("process poll waits for completion when timeout is provided", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const started = Date.now();
const run = await execTool.execute("toolcall", {
command: sleepAndEcho,
background: true,
});
expect(run.details.status).toBe("running");
const sessionId = run.details.sessionId;
const poll = await processTool.execute("toolcall", {
action: "poll",
sessionId,
timeout: 2000,
});
const elapsedMs = Date.now() - started;
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("done");
expect(elapsedMs).toBeGreaterThanOrEqual(200);
});
test("process poll accepts string timeout values", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const run = await execTool.execute("toolcall", {
command: sleepAndEcho,
background: true,
});
expect(run.details.status).toBe("running");
const sessionId = run.details.sessionId;
const poll = await processTool.execute("toolcall", {
action: "poll",
sessionId,
timeout: "2000",
});
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("done");
});

View File

@@ -64,8 +64,28 @@ const processSchema = Type.Object({
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
offset: Type.Optional(Type.Number({ description: "Log offset" })),
limit: Type.Optional(Type.Number({ description: "Log length" })),
timeout: Type.Optional(
Type.Union([Type.Number(), Type.String()], {
description: "For poll: wait up to this many milliseconds before returning",
}),
),
});
const MAX_POLL_WAIT_MS = 120_000;
function resolvePollWaitMs(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.min(MAX_POLL_WAIT_MS, Math.floor(value)));
}
if (typeof value === "string") {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.min(MAX_POLL_WAIT_MS, parsed));
}
}
return 0;
}
export function createProcessTool(
defaults?: ProcessToolDefaults,
// oxlint-disable-next-line typescript/no-explicit-any
@@ -106,6 +126,7 @@ export function createProcessTool(
eof?: boolean;
offset?: number;
limit?: number;
timeout?: number | string;
};
if (params.action === "list") {
@@ -258,6 +279,15 @@ export function createProcessTool(
details: { status: "failed" },
};
}
const pollWaitMs = resolvePollWaitMs(params.timeout);
if (pollWaitMs > 0 && !scopedSession.exited) {
const deadline = Date.now() + pollWaitMs;
while (!scopedSession.exited && Date.now() < deadline) {
await new Promise((resolve) =>
setTimeout(resolve, Math.min(250, deadline - Date.now())),
);
}
}
const { stdout, stderr } = drainSession(scopedSession);
const exited = scopedSession.exited;
const exitCode = scopedSession.exitCode ?? 0;

View File

@@ -24,6 +24,22 @@ function makeCfg(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
}
describe("runWithModelFallback", () => {
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
const cfg = makeCfg();
const run = vi.fn().mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.3-codex",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("openai-codex", "gpt-5.3-codex");
});
it("does not fall back on non-auth errors", async () => {
const cfg = makeCfg();
const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok");

View File

@@ -16,9 +16,11 @@ import {
buildConfiguredAllowlistKeys,
buildModelAliasIndex,
modelKey,
normalizeModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "./model-selection.js";
import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
type ModelCandidate = {
provider: string;
@@ -143,8 +145,9 @@ function resolveFallbackCandidates(params: {
: null;
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
const defaultModel = primary?.model ?? DEFAULT_MODEL;
const provider = String(params.provider ?? "").trim() || defaultProvider;
const model = String(params.model ?? "").trim() || defaultModel;
const providerRaw = String(params.provider ?? "").trim() || defaultProvider;
const modelRaw = String(params.model ?? "").trim() || defaultModel;
const normalizedPrimary = normalizeModelRef(providerRaw, modelRaw);
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider,
@@ -171,7 +174,7 @@ function resolveFallbackCandidates(params: {
candidates.push(candidate);
};
addCandidate({ provider, model }, false);
addCandidate(normalizedPrimary, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) {
@@ -272,6 +275,14 @@ export async function runWithModelFallback<T>(params: {
if (shouldRethrowAbort(err)) {
throw err;
}
// Context overflow errors should be handled by the inner runner's
// compaction/retry logic, not by model fallback. If one escapes as a
// throw, rethrow it immediately rather than trying a different model
// that may have a smaller context window and fail worse.
const errMessage = err instanceof Error ? err.message : String(err);
if (isLikelyContextOverflowError(errMessage)) {
throw err;
}
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,

View File

@@ -54,6 +54,21 @@ describe("model-selection", () => {
});
});
it("normalizes openai gpt-5.3 codex refs to openai-codex provider", () => {
expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({
provider: "openai-codex",
model: "gpt-5.3-codex",
});
expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({
provider: "openai-codex",
model: "gpt-5.3-codex",
});
expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({
provider: "openai-codex",
model: "gpt-5.3-codex-codex",
});
});
it("should return null for empty strings", () => {
expect(parseModelRef("", "anthropic")).toBeNull();
expect(parseModelRef(" ", "anthropic")).toBeNull();

View File

@@ -21,6 +21,7 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
"opus-4.5": "claude-opus-4-5",
"sonnet-4.5": "claude-sonnet-4-5",
};
const OPENAI_CODEX_OAUTH_MODEL_PREFIXES = ["gpt-5.3-codex"] as const;
function normalizeAliasKey(value: string): string {
return value.trim().toLowerCase();
@@ -78,6 +79,28 @@ function normalizeProviderModelId(provider: string, model: string): string {
return model;
}
function shouldUseOpenAICodexProvider(provider: string, model: string): boolean {
if (provider !== "openai") {
return false;
}
const normalized = model.trim().toLowerCase();
if (!normalized) {
return false;
}
return OPENAI_CODEX_OAUTH_MODEL_PREFIXES.some(
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`),
);
}
export function normalizeModelRef(provider: string, model: string): ModelRef {
const normalizedProvider = normalizeProviderId(provider);
const normalizedModel = normalizeProviderModelId(normalizedProvider, model.trim());
if (shouldUseOpenAICodexProvider(normalizedProvider, normalizedModel)) {
return { provider: "openai-codex", model: normalizedModel };
}
return { provider: normalizedProvider, model: normalizedModel };
}
export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
const trimmed = raw.trim();
if (!trimmed) {
@@ -85,18 +108,14 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef |
}
const slash = trimmed.indexOf("/");
if (slash === -1) {
const provider = normalizeProviderId(defaultProvider);
const model = normalizeProviderModelId(provider, trimmed);
return { provider, model };
return normalizeModelRef(defaultProvider, trimmed);
}
const providerRaw = trimmed.slice(0, slash).trim();
const provider = normalizeProviderId(providerRaw);
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) {
if (!providerRaw || !model) {
return null;
}
const normalizedModel = normalizeProviderModelId(provider, model);
return { provider, model: normalizedModel };
return normalizeModelRef(providerRaw, model);
}
export function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null {

View File

@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import {
addSubagentRunForTests,
listSubagentRunsForRequester,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
@@ -72,7 +77,7 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "thinking").type).toBe("string");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
expect(schemaProp("subagents", "recentMinutes").type).toBe("number");
});
it("sessions_list filters kinds and includes messages", async () => {
@@ -672,4 +677,333 @@ describe("sessions tools", () => {
message: "announce now",
});
});
it("subagents lists active and recent runs", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const now = Date.now();
addSubagentRunForTests({
runId: "run-active",
childSessionKey: "agent:main:subagent:active",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "investigate auth",
cleanup: "keep",
createdAt: now - 2 * 60_000,
startedAt: now - 2 * 60_000,
});
addSubagentRunForTests({
runId: "run-recent",
childSessionKey: "agent:main:subagent:recent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "summarize findings",
cleanup: "keep",
createdAt: now - 15 * 60_000,
startedAt: now - 14 * 60_000,
endedAt: now - 5 * 60_000,
outcome: { status: "ok" },
});
addSubagentRunForTests({
runId: "run-old",
childSessionKey: "agent:main:subagent:old",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "old completed run",
cleanup: "keep",
createdAt: now - 90 * 60_000,
startedAt: now - 89 * 60_000,
endedAt: now - 80 * 60_000,
outcome: { status: "ok" },
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-list", { action: "list" });
const details = result.details as {
status?: string;
active?: unknown[];
recent?: unknown[];
text?: string;
};
expect(details.status).toBe("ok");
expect(details.active).toHaveLength(1);
expect(details.recent).toHaveLength(1);
expect(details.text).toContain("active subagents:");
expect(details.text).toContain("recent (last 30m):");
resetSubagentRegistryForTests();
});
it("subagents list usage separates io tokens from prompt/cache", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const now = Date.now();
addSubagentRunForTests({
runId: "run-usage-active",
childSessionKey: "agent:main:subagent:usage-active",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait and check weather",
cleanup: "keep",
createdAt: now - 2 * 60_000,
startedAt: now - 2 * 60_000,
});
const sessionsModule = await import("../config/sessions.js");
const loadSessionStoreSpy = vi
.spyOn(sessionsModule, "loadSessionStore")
.mockImplementation(() => ({
"agent:main:subagent:usage-active": {
modelProvider: "anthropic",
model: "claude-opus-4-6",
inputTokens: 12,
outputTokens: 1000,
totalTokens: 197000,
},
}));
try {
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-list-usage", { action: "list" });
const details = result.details as {
status?: string;
text?: string;
};
expect(details.status).toBe("ok");
expect(details.text).toContain("tokens 1k (in 12 / out 1k)");
expect(details.text).toContain("prompt/cache 197k");
expect(details.text).not.toContain("1.0k io");
} finally {
loadSessionStoreSpy.mockRestore();
resetSubagentRegistryForTests();
}
});
it("subagents steer sends guidance to a running run", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "agent") {
return { runId: "run-steer-1" };
}
return {};
});
addSubagentRunForTests({
runId: "run-steer",
childSessionKey: "agent:main:subagent:steer",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "prepare release notes",
cleanup: "keep",
createdAt: Date.now() - 60_000,
startedAt: Date.now() - 60_000,
});
const sessionsModule = await import("../config/sessions.js");
const loadSessionStoreSpy = vi
.spyOn(sessionsModule, "loadSessionStore")
.mockImplementation(() => ({
"agent:main:subagent:steer": {
sessionId: "child-session-steer",
updatedAt: Date.now(),
},
}));
try {
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-steer", {
action: "steer",
target: "1",
message: "skip changelog and focus on tests",
});
const details = result.details as { status?: string; runId?: string; text?: string };
expect(details.status).toBe("accepted");
expect(details.runId).toBe("run-steer-1");
expect(details.text).toContain("steered");
const steerWaitIndex = callGatewayMock.mock.calls.findIndex(
(call) =>
(call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" &&
(call[0] as { method?: string; params?: { runId?: string } }).params?.runId ===
"run-steer",
);
expect(steerWaitIndex).toBeGreaterThanOrEqual(0);
const steerRunIndex = callGatewayMock.mock.calls.findIndex(
(call) => (call[0] as { method?: string }).method === "agent",
);
expect(steerRunIndex).toBeGreaterThan(steerWaitIndex);
expect(callGatewayMock.mock.calls[steerWaitIndex]?.[0]).toMatchObject({
method: "agent.wait",
params: { runId: "run-steer", timeoutMs: 5_000 },
timeoutMs: 7_000,
});
expect(callGatewayMock.mock.calls[steerRunIndex]?.[0]).toMatchObject({
method: "agent",
params: {
lane: "subagent",
sessionKey: "agent:main:subagent:steer",
sessionId: "child-session-steer",
timeout: 0,
},
});
const trackedRuns = listSubagentRunsForRequester("agent:main:main");
expect(trackedRuns).toHaveLength(1);
expect(trackedRuns[0].runId).toBe("run-steer-1");
expect(trackedRuns[0].endedAt).toBeUndefined();
} finally {
loadSessionStoreSpy.mockRestore();
resetSubagentRegistryForTests();
}
});
it("subagents numeric targets follow active-first list ordering", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
addSubagentRunForTests({
runId: "run-active",
childSessionKey: "agent:main:subagent:active",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "active task",
cleanup: "keep",
createdAt: Date.now() - 120_000,
startedAt: Date.now() - 120_000,
});
addSubagentRunForTests({
runId: "run-recent",
childSessionKey: "agent:main:subagent:recent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "recent task",
cleanup: "keep",
createdAt: Date.now() - 30_000,
startedAt: Date.now() - 30_000,
endedAt: Date.now() - 10_000,
outcome: { status: "ok" },
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-kill-order", {
action: "kill",
target: "1",
});
const details = result.details as { status?: string; runId?: string; text?: string };
expect(details.status).toBe("ok");
expect(details.runId).toBe("run-active");
expect(details.text).toContain("killed");
resetSubagentRegistryForTests();
});
it("subagents kill stops a running run", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
addSubagentRunForTests({
runId: "run-kill",
childSessionKey: "agent:main:subagent:kill",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "long running task",
cleanup: "keep",
createdAt: Date.now() - 60_000,
startedAt: Date.now() - 60_000,
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-kill", {
action: "kill",
target: "1",
});
const details = result.details as { status?: string; text?: string };
expect(details.status).toBe("ok");
expect(details.text).toContain("killed");
resetSubagentRegistryForTests();
});
it("subagents kill-all cascades through ended parents to active descendants", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const now = Date.now();
const endedParentKey = "agent:main:subagent:parent-ended";
const activeChildKey = "agent:main:subagent:parent-ended:subagent:worker";
addSubagentRunForTests({
runId: "run-parent-ended",
childSessionKey: endedParentKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrator",
cleanup: "keep",
createdAt: now - 120_000,
startedAt: now - 120_000,
endedAt: now - 60_000,
outcome: { status: "ok" },
});
addSubagentRunForTests({
runId: "run-worker-active",
childSessionKey: activeChildKey,
requesterSessionKey: endedParentKey,
requesterDisplayKey: endedParentKey,
task: "leaf worker",
cleanup: "keep",
createdAt: now - 30_000,
startedAt: now - 30_000,
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-kill-all-cascade-ended", {
action: "kill",
target: "all",
});
const details = result.details as { status?: string; killed?: number; text?: string };
expect(details.status).toBe("ok");
expect(details.killed).toBe(1);
expect(details.text).toContain("killed 1 subagent");
const descendants = listSubagentRunsForRequester(endedParentKey);
const worker = descendants.find((entry) => entry.runId === "run-worker-active");
expect(worker?.endedAt).toBeTypeOf("number");
resetSubagentRegistryForTests();
});
});

View File

@@ -0,0 +1,288 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let storeTemplatePath = "";
let configOverride: Record<string, unknown> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
};
});
function writeStore(agentId: string, store: Record<string, unknown>) {
const storePath = storeTemplatePath.replaceAll("{agentId}", agentId);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
}
describe("sessions_spawn depth + child limits", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
storeTemplatePath = path.join(
os.tmpdir(),
`openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`,
);
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string };
if (req.method === "agent") {
return { runId: "run-depth" };
}
if (req.method === "agent.wait") {
return { status: "running" };
}
return {};
});
});
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
const result = await tool.execute("call-depth-reject", { task: "hello" });
expect(result.details).toMatchObject({
status: "forbidden",
error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)",
});
});
it("allows depth-1 callers when maxSpawnDepth is 2", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
const result = await tool.execute("call-depth-allow", { task: "hello" });
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
runId: "run-depth",
});
const calls = callGatewayMock.mock.calls.map(
(call) => call[0] as { method?: string; params?: Record<string, unknown> },
);
const agentCall = calls.find((entry) => entry.method === "agent");
expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent");
const spawnDepthPatch = calls.find(
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
);
expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/);
});
it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const callerKey = "agent:main:subagent:flat-depth-2";
writeStore("main", {
[callerKey]: {
sessionId: "flat-depth-2",
updatedAt: Date.now(),
spawnDepth: 2,
},
});
const tool = createSessionsSpawnTool({ agentSessionKey: callerKey });
const result = await tool.execute("call-depth-2-reject", { task: "hello" });
expect(result.details).toMatchObject({
status: "forbidden",
error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)",
});
});
it("rejects depth-2 callers when spawnDepth is missing but spawnedBy ancestry implies depth 2", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const depth1 = "agent:main:subagent:depth-1";
const callerKey = "agent:main:subagent:depth-2";
writeStore("main", {
[depth1]: {
sessionId: "depth-1",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[callerKey]: {
sessionId: "depth-2",
updatedAt: Date.now(),
spawnedBy: depth1,
},
});
const tool = createSessionsSpawnTool({ agentSessionKey: callerKey });
const result = await tool.execute("call-depth-ancestry-reject", { task: "hello" });
expect(result.details).toMatchObject({
status: "forbidden",
error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)",
});
});
it("rejects depth-2 callers when the requester key is a sessionId", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const depth1 = "agent:main:subagent:depth-1";
const callerKey = "agent:main:subagent:depth-2";
writeStore("main", {
[depth1]: {
sessionId: "depth-1-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[callerKey]: {
sessionId: "depth-2-session",
updatedAt: Date.now(),
spawnedBy: depth1,
},
});
const tool = createSessionsSpawnTool({ agentSessionKey: "depth-2-session" });
const result = await tool.execute("call-depth-sessionid-reject", { task: "hello" });
expect(result.details).toMatchObject({
status: "forbidden",
error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)",
});
});
it("rejects when active children for requester session reached maxChildrenPerAgent", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
maxChildrenPerAgent: 1,
},
},
},
};
addSubagentRunForTests({
runId: "existing-run",
childSessionKey: "agent:main:subagent:existing",
requesterSessionKey: "agent:main:subagent:parent",
requesterDisplayKey: "agent:main:subagent:parent",
task: "existing",
cleanup: "keep",
createdAt: Date.now(),
startedAt: Date.now(),
});
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
const result = await tool.execute("call-max-children", { task: "hello" });
expect(result.details).toMatchObject({
status: "forbidden",
error: "sessions_spawn has reached max active children for this session (1/1)",
});
});
it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
maxChildrenPerAgent: 5,
maxConcurrent: 1,
},
},
},
};
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
const result = await tool.execute("call-max-concurrent-independent", { task: "hello" });
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-depth",
});
});
});

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOpenClawTools } from "./openclaw-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
@@ -113,7 +114,9 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
expect(patchIndex).toBeGreaterThan(-1);
expect(agentIndex).toBeGreaterThan(-1);
expect(patchIndex).toBeLessThan(agentIndex);
const patchCall = calls[patchIndex];
const patchCall = calls.find(
(call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model,
);
expect(patchCall?.params).toMatchObject({
key: expect.stringContaining("subagent:"),
model: "claude-haiku-4-5",
@@ -223,12 +226,55 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
modelApplied: true,
});
const patchCall = calls.find((call) => call.method === "sessions.patch");
const patchCall = calls.find(
(call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model,
);
expect(patchCall?.params).toMatchObject({
model: "minimax/MiniMax-M2.1",
});
});
it("sessions_spawn falls back to runtime default model when no model config is set", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-runtime-default-model", status: "accepted" };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) {
throw new Error("missing sessions_spawn tool");
}
const result = await tool.execute("call-runtime-default-model", {
task: "do thing",
});
expect(result.details).toMatchObject({
status: "accepted",
modelApplied: true,
});
const patchCall = calls.find(
(call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model,
);
expect(patchCall?.params).toMatchObject({
model: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`,
});
});
it("sessions_spawn prefers per-agent subagent model over defaults", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();

View File

@@ -0,0 +1,102 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
};
});
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import {
addSubagentRunForTests,
listSubagentRunsForRequester,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
describe("openclaw-tools: subagents steer failure", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const storePath = path.join(
os.tmpdir(),
`openclaw-subagents-steer-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storePath,
},
};
fs.writeFileSync(storePath, "{}", "utf-8");
});
it("restores announce behavior when steer replacement dispatch fails", async () => {
addSubagentRunForTests({
runId: "run-old",
childSessionKey: "agent:main:subagent:worker",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do work",
cleanup: "keep",
createdAt: Date.now(),
startedAt: Date.now(),
});
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "agent.wait") {
return { status: "timeout" };
}
if (request.method === "agent") {
throw new Error("dispatch failed");
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
}).find((candidate) => candidate.name === "subagents");
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-steer", {
action: "steer",
target: "1",
message: "new direction",
});
expect(result.details).toMatchObject({
status: "error",
action: "steer",
runId: expect.any(String),
error: "dispatch failed",
});
const runs = listSubagentRunsForRequester("agent:main:main");
expect(runs).toHaveLength(1);
expect(runs[0].runId).toBe("run-old");
expect(runs[0].suppressAnnounceReason).toBeUndefined();
});
});

View File

@@ -17,6 +17,7 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSubagentsTool } from "./tools/subagents-tool.js";
import { createTtsTool } from "./tools/tts-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
@@ -147,6 +148,9 @@ export function createOpenClawTools(options?: {
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
}),
createSubagentsTool({
agentSessionKey: options?.agentSessionKey,
}),
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,

View File

@@ -59,6 +59,17 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("does not rewrite normal text that mentions billing and plan", () => {
const text =
"Firebase downgraded us to the free Spark plan; check whether we need to re-enable billing.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("rewrites billing error-shaped text", () => {
const text = "billing: please upgrade your plan";
expect(sanitizeUserFacingText(text)).toContain("billing error");
});
it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe(

View File

@@ -107,6 +107,8 @@ const ERROR_PREFIX_RE =
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
const BILLING_ERROR_HEAD_RE =
/^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i;
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
@@ -238,6 +240,18 @@ function shouldRewriteContextOverflowText(raw: string): boolean {
);
}
function shouldRewriteBillingText(raw: string): boolean {
if (!isBillingErrorMessage(raw)) {
return false;
}
return (
isRawApiErrorPayload(raw) ||
isLikelyHttpErrorText(raw) ||
ERROR_PREFIX_RE.test(raw) ||
BILLING_ERROR_HEAD_RE.test(raw)
);
}
type ErrorPayload = Record<string, unknown>;
function isErrorPayloadObject(payload: unknown): payload is ErrorPayload {
@@ -547,6 +561,13 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
}
}
// Preserve legacy behavior for explicit billing-head text outside known
// error contexts (e.g., "billing: please upgrade your plan"), while
// keeping conversational billing mentions untouched.
if (shouldRewriteBillingText(trimmed)) {
return BILLING_ERROR_USER_MESSAGE;
}
// Strip leading blank lines (including whitespace-only lines) without clobbering indentation on
// the first content line (e.g. markdown/code blocks).
const withoutLeadingEmptyLines = stripped.replace(/^(?:[ \t]*\r?\n)+/, "");
@@ -646,8 +667,18 @@ export function isBillingErrorMessage(raw: string): boolean {
if (!value) {
return false;
}
return matchesErrorPatterns(value, ERROR_PATTERNS.billing);
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) {
return true;
}
if (!BILLING_ERROR_HEAD_RE.test(raw)) {
return false;
}
return (
value.includes("upgrade") ||
value.includes("credits") ||
value.includes("payment") ||
value.includes("plan")
);
}
export function isMissingToolCallInputError(raw: string): boolean {

View File

@@ -16,7 +16,7 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
@@ -469,7 +469,10 @@ export async function compactEmbeddedPiSessionDirect(
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const promptMode =
isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey)
? "minimal"
: "full";
const docsPath = await resolveOpenClawDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],

View File

@@ -494,7 +494,8 @@ export async function runEmbeddedPiAgent(
// Keep prompt size from the latest model call so session totalTokens
// reflects current context usage, not accumulated tool-loop usage.
lastRunPromptUsage = lastAssistantUsage ?? attemptUsage;
autoCompactionCount += Math.max(0, attempt.compactionCount ?? 0);
const attemptCompactionCount = Math.max(0, attempt.compactionCount ?? 0);
autoCompactionCount += attemptCompactionCount;
const formattedAssistantErrorText = lastAssistant
? formatAssistantErrorText(lastAssistant, {
cfg: params.config,
@@ -537,9 +538,25 @@ export async function runEmbeddedPiAgent(
`error=${errorText.slice(0, 200)}`,
);
const isCompactionFailure = isCompactionFailureError(errorText);
// Attempt auto-compaction on context overflow (not compaction_failure)
const hadAttemptLevelCompaction = attemptCompactionCount > 0;
// If this attempt already compacted (SDK auto-compaction), avoid immediately
// running another explicit compaction for the same overflow trigger.
if (
!isCompactionFailure &&
hadAttemptLevelCompaction &&
overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
) {
overflowCompactionAttempts++;
log.warn(
`context overflow persisted after in-attempt compaction (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); retrying prompt without additional compaction for ${provider}/${modelId}`,
);
continue;
}
// Attempt explicit overflow compaction only when this attempt did not
// already auto-compact.
if (
!isCompactionFailure &&
!hadAttemptLevelCompaction &&
overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
) {
if (log.isEnabled("debug")) {

View File

@@ -10,7 +10,11 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { isSubagentSessionKey, normalizeAgentId } from "../../../routing/session-key.js";
import {
isCronSessionKey,
isSubagentSessionKey,
normalizeAgentId,
} from "../../../routing/session-key.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
@@ -410,7 +414,10 @@ export async function runEmbeddedAttempt(
},
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const promptMode =
isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey)
? "minimal"
: "full";
const docsPath = await resolveOpenClawDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],

View File

@@ -92,6 +92,24 @@ describe("extractAssistantText", () => {
expect(result).toBe("HTTP 500: Internal Server Error");
});
it("does not rewrite normal text that references billing plans", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Firebase downgraded Chore Champ to the Spark plan; confirm whether billing should be re-enabled.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe(
"Firebase downgraded Chore Champ to the Spark plan; confirm whether billing should be re-enabled.",
);
});
it("strips Minimax tool invocations with extra attributes", () => {
const msg: AssistantMessage = {
role: "assistant",

View File

@@ -274,6 +274,7 @@ describe("createOpenClawCodingTools", () => {
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status",
"image",
]);
@@ -296,12 +297,56 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("sessions_history")).toBe(false);
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
// Explicit subagent orchestration tool remains available (list/steer/kill with safeguards).
expect(names.has("subagents")).toBe(true);
expect(names.has("read")).toBe(true);
expect(names.has("exec")).toBe(true);
expect(names.has("process")).toBe(true);
expect(names.has("apply_patch")).toBe(false);
});
it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-"));
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const storePath = storeTemplate.replaceAll("{agentId}", "main");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:subagent:flat": {
sessionId: "session-flat-depth-2",
updatedAt: Date.now(),
spawnDepth: 2,
},
},
null,
2,
),
"utf-8",
);
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:flat",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("subagents")).toBe(true);
});
it("supports allow-only sub-agent tool policy", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",

View File

@@ -1,6 +1,11 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { filterToolsByPolicy, isToolAllowedByPolicyName } from "./pi-tools.policy.js";
import type { OpenClawConfig } from "../config/config.js";
import {
filterToolsByPolicy,
isToolAllowedByPolicyName,
resolveSubagentToolPolicy,
} from "./pi-tools.policy.js";
function createStubTool(name: string): AgentTool<unknown, unknown> {
return {
@@ -34,3 +39,93 @@ describe("pi-tools.policy", () => {
expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true);
});
});
describe("resolveSubagentToolPolicy depth awareness", () => {
const baseCfg = {
agents: { defaults: { subagents: { maxSpawnDepth: 2 } } },
} as unknown as OpenClawConfig;
const deepCfg = {
agents: { defaults: { subagents: { maxSpawnDepth: 3 } } },
} as unknown as OpenClawConfig;
const leafCfg = {
agents: { defaults: { subagents: { maxSpawnDepth: 1 } } },
} as unknown as OpenClawConfig;
it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_spawn", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 1);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true);
});
it("depth-1 orchestrator (maxSpawnDepth=2) allows subagents", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 1);
expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true);
});
it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_list", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 1);
expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(true);
});
it("depth-1 orchestrator (maxSpawnDepth=2) allows sessions_history", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 1);
expect(isToolAllowedByPolicyName("sessions_history", policy)).toBe(true);
});
it("depth-1 orchestrator still denies gateway, cron, memory", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 1);
expect(isToolAllowedByPolicyName("gateway", policy)).toBe(false);
expect(isToolAllowedByPolicyName("cron", policy)).toBe(false);
expect(isToolAllowedByPolicyName("memory_search", policy)).toBe(false);
expect(isToolAllowedByPolicyName("memory_get", policy)).toBe(false);
});
it("depth-2 leaf denies sessions_spawn", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 2);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
it("depth-2 orchestrator (maxSpawnDepth=3) allows sessions_spawn", () => {
const policy = resolveSubagentToolPolicy(deepCfg, 2);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true);
});
it("depth-3 leaf (maxSpawnDepth=3) denies sessions_spawn", () => {
const policy = resolveSubagentToolPolicy(deepCfg, 3);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
it("depth-2 leaf allows subagents (for visibility)", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 2);
expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true);
});
it("depth-2 leaf denies sessions_list and sessions_history", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 2);
expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false);
expect(isToolAllowedByPolicyName("sessions_history", policy)).toBe(false);
});
it("depth-1 leaf (maxSpawnDepth=1) denies sessions_spawn", () => {
const policy = resolveSubagentToolPolicy(leafCfg, 1);
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
it("depth-1 leaf (maxSpawnDepth=1) denies sessions_list", () => {
const policy = resolveSubagentToolPolicy(leafCfg, 1);
expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false);
});
it("defaults to leaf behavior when no depth is provided", () => {
const policy = resolveSubagentToolPolicy(baseCfg);
// Default depth=1, maxSpawnDepth=2 → orchestrator
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(true);
});
it("defaults to leaf behavior when depth is undefined and maxSpawnDepth is 1", () => {
const policy = resolveSubagentToolPolicy(leafCfg);
// Default depth=1, maxSpawnDepth=1 → leaf
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
});

View File

@@ -36,12 +36,11 @@ function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
};
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
// Session management - main agent orchestrates
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
/**
* Tools always denied for sub-agents regardless of depth.
* These are system-level or interactive tools that sub-agents should never use.
*/
const SUBAGENT_TOOL_DENY_ALWAYS = [
// System admin - dangerous from subagent
"gateway",
"agents_list",
@@ -53,14 +52,40 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
// Memory - pass relevant info in spawn prompt instead
"memory_search",
"memory_get",
// Direct session sends - subagents communicate through announce chain
"sessions_send",
];
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig): SandboxToolPolicy {
/**
* Additional tools denied for leaf sub-agents (depth >= maxSpawnDepth).
* These are tools that only make sense for orchestrator sub-agents that can spawn children.
*/
const SUBAGENT_TOOL_DENY_LEAF = ["sessions_list", "sessions_history", "sessions_spawn"];
/**
* Build the deny list for a sub-agent at a given depth.
*
* - Depth 1 with maxSpawnDepth >= 2 (orchestrator): allowed to use sessions_spawn,
* subagents, sessions_list, sessions_history so it can manage its children.
* - Depth >= maxSpawnDepth (leaf): denied sessions_spawn and
* session management tools. Still allowed subagents (for list/status visibility).
*/
function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] {
const isLeaf = depth >= Math.max(1, Math.floor(maxSpawnDepth));
if (isLeaf) {
return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF];
}
// Orchestrator sub-agent: only deny the always-denied tools.
// sessions_spawn, subagents, sessions_list, sessions_history are allowed.
return [...SUBAGENT_TOOL_DENY_ALWAYS];
}
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1;
const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth);
const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])];
const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
return { allow, deny };
}

View File

@@ -44,6 +44,7 @@ import {
wrapToolParamNormalization,
} from "./pi-tools.read.js";
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
@@ -236,7 +237,10 @@ export function createOpenClawCodingTools(options?: {
options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(options.config)
? resolveSubagentToolPolicy(
options.config,
getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }),
)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,

View File

@@ -22,6 +22,7 @@ export const DEFAULT_TOOL_ALLOW = [
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status",
] as const;

View File

@@ -9,6 +9,11 @@ const embeddedRunMock = {
queueEmbeddedPiMessage: vi.fn(() => false),
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
};
const subagentRegistryMock = {
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn(() => 0),
resolveRequesterForChildSession: vi.fn(() => null),
};
let sessionStore: Record<string, Record<string, unknown>> = {};
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
@@ -52,6 +57,8 @@ vi.mock("../config/sessions.js", () => ({
vi.mock("./pi-embedded.js", () => embeddedRunMock);
vi.mock("./subagent-registry.js", () => subagentRegistryMock);
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
@@ -68,6 +75,9 @@ describe("subagent announce formatting", () => {
embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
embeddedRunMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true);
subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true);
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
sessionStore = {};
configOverride = {
@@ -80,6 +90,11 @@ describe("subagent announce formatting", () => {
it("sends instructional message to main agent with status and findings", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-123",
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-123",
@@ -99,12 +114,17 @@ describe("subagent announce formatting", () => {
};
const msg = call?.params?.message as string;
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(msg).toContain("[System Message]");
expect(msg).toContain("[sessionId: child-session-123]");
expect(msg).toContain("subagent task");
expect(msg).toContain("failed");
expect(msg).toContain("boom");
expect(msg).toContain("Findings:");
expect(msg).toContain("Result:");
expect(msg).toContain("raw subagent reply");
expect(msg).toContain("Stats:");
expect(msg).toContain("A completed subagent task is ready for user delivery.");
expect(msg).toContain("Convert the result above into your normal assistant voice");
expect(msg).toContain("Keep this internal context private");
});
it("includes success status when outcome is ok", async () => {
@@ -129,6 +149,49 @@ describe("subagent announce formatting", () => {
expect(msg).toContain("completed successfully");
});
it("keeps full findings and includes compact stats", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-usage",
inputTokens: 12,
outputTokens: 1000,
totalTokens: 197000,
},
};
readLatestAssistantReplyMock.mockResolvedValue(
Array.from({ length: 140 }, (_, index) => `step-${index}`).join(" "),
);
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-usage",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("Result:");
expect(msg).toContain("Stats:");
expect(msg).toContain("tokens 1.0k (in 12 / out 1.0k)");
expect(msg).toContain("prompt/cache 197.0k");
expect(msg).toContain("[sessionId: child-session-usage]");
expect(msg).toContain("A completed subagent task is ready for user delivery.");
expect(msg).toContain(
"Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.",
);
expect(msg).toContain("step-0");
expect(msg).toContain("step-139");
});
it("steers announcements into an active run when queue mode is steer", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
@@ -160,7 +223,7 @@ describe("subagent announce formatting", () => {
expect(didAnnounce).toBe(true);
expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith(
"session-123",
expect.stringContaining("subagent task"),
expect.stringContaining("[System Message]"),
);
expect(agentSpy).not.toHaveBeenCalled();
});
@@ -203,6 +266,44 @@ describe("subagent announce formatting", () => {
expect(call?.params?.accountId).toBe("kev");
});
it("queues announce delivery back into requester subagent session", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:subagent:orchestrator": {
sessionId: "session-orchestrator",
spawnDepth: 1,
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-worker-queued",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator");
expect(call?.params?.deliver).toBe(false);
expect(call?.params?.channel).toBeUndefined();
expect(call?.params?.to).toBeUndefined();
});
it("includes threadId when origin has an active topic/thread", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
@@ -356,9 +457,41 @@ describe("subagent announce formatting", () => {
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
const call = agentSpy.mock.calls[0]?.[0] as {
params?: Record<string, unknown>;
expectFinal?: boolean;
};
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.accountId).toBe("acct-123");
expect(call?.expectFinal).toBe(true);
});
it("injects direct announce into requester subagent session instead of chat channel", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-worker",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" },
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator");
expect(call?.params?.deliver).toBe(false);
expect(call?.params?.channel).toBeUndefined();
expect(call?.params?.to).toBeUndefined();
});
it("retries reading subagent output when early lifecycle completion had no text", async () => {
@@ -394,6 +527,117 @@ describe("subagent announce formatting", () => {
expect(call?.params?.message).not.toContain("(no output)");
});
it("uses advisory guidance when sibling subagents are still active", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:main" ? 2 : 0,
);
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("There are still 2 active subagent runs for this session.");
expect(msg).toContain(
"If they are part of the same workflow, wait for the remaining results before sending a user update.",
);
expect(msg).toContain("If they are unrelated, respond normally using only the result above.");
});
it("defers announce while the finished run still has active descendants", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:subagent:parent" ? 1 : 0,
);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:parent",
childRunId: "run-parent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(agentSpy).not.toHaveBeenCalled();
});
it("bubbles child announce to parent requester when requester subagent already ended", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct-main" },
});
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:leaf",
childRunId: "run-leaf",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(call?.params?.deliver).toBe(true);
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.to).toBe("+1555");
expect(call?.params?.accountId).toBe("acct-main");
});
it("keeps announce retryable when ended requester subagent has no fallback requester", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue(null);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:leaf",
childRunId: "run-leaf-missing-fallback",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(subagentRegistryMock.resolveRequesterForChildSession).toHaveBeenCalledWith(
"agent:main:subagent:orchestrator",
);
expect(agentSpy).not.toHaveBeenCalled();
expect(sessionsDeleteSpy).not.toHaveBeenCalled();
});
it("defers announce when child run is still active after wait timeout", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);

View File

@@ -1,16 +1,13 @@
import crypto from "node:crypto";
import path from "node:path";
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveSessionFilePath,
resolveStorePath,
} from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -25,10 +22,28 @@ import {
waitForEmbeddedPiRunEnd,
} from "./pi-embedded.js";
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const totalSeconds = Math.round(valueMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m${seconds}s`;
}
return `${seconds}s`;
}
function formatTokenCount(value?: number) {
if (!value || !Number.isFinite(value)) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return "0";
}
if (value >= 1_000_000) {
@@ -40,65 +55,44 @@ function formatTokenCount(value?: number) {
return String(Math.round(value));
}
function formatUsd(value?: number) {
if (value === undefined || !Number.isFinite(value)) {
return undefined;
}
if (value >= 1) {
return `$${value.toFixed(2)}`;
}
if (value >= 0.01) {
return `$${value.toFixed(2)}`;
}
return `$${value.toFixed(4)}`;
}
function resolveModelCost(params: {
provider?: string;
model?: string;
config: ReturnType<typeof loadConfig>;
}):
| {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
| undefined {
const provider = params.provider?.trim();
const model = params.model?.trim();
if (!provider || !model) {
return undefined;
}
const models = params.config.models?.providers?.[provider]?.models ?? [];
const entry = models.find((candidate) => candidate.id === model);
return entry?.cost;
}
async function waitForSessionUsage(params: { sessionKey: string }) {
async function buildCompactAnnounceStatsLine(params: {
sessionKey: string;
startedAt?: number;
endedAt?: number;
}) {
const cfg = loadConfig();
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
let entry = loadSessionStore(storePath)[params.sessionKey];
if (!entry) {
return { entry, storePath };
}
const hasTokens = () =>
entry &&
(typeof entry.totalTokens === "number" ||
typeof entry.inputTokens === "number" ||
typeof entry.outputTokens === "number");
if (hasTokens()) {
return { entry, storePath };
}
for (let attempt = 0; attempt < 4; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 200));
entry = loadSessionStore(storePath)[params.sessionKey];
if (hasTokens()) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const hasTokenData =
typeof entry?.inputTokens === "number" ||
typeof entry?.outputTokens === "number" ||
typeof entry?.totalTokens === "number";
if (hasTokenData) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
entry = loadSessionStore(storePath)[params.sessionKey];
}
return { entry, storePath };
const input = typeof entry?.inputTokens === "number" ? entry.inputTokens : 0;
const output = typeof entry?.outputTokens === "number" ? entry.outputTokens : 0;
const ioTotal = input + output;
const promptCache = typeof entry?.totalTokens === "number" ? entry.totalTokens : undefined;
const runtimeMs =
typeof params.startedAt === "number" && typeof params.endedAt === "number"
? Math.max(0, params.endedAt - params.startedAt)
: undefined;
const parts = [
`runtime ${formatDurationShort(runtimeMs)}`,
`tokens ${formatTokenCount(ioTotal)} (in ${formatTokenCount(input)} / out ${formatTokenCount(output)})`,
];
if (typeof promptCache === "number" && promptCache > ioTotal) {
parts.push(`prompt/cache ${formatTokenCount(promptCache)}`);
}
return `Stats: ${parts.join(" • ")}`;
}
type DeliveryContextSource = Parameters<typeof deliveryContextFromSession>[0];
@@ -114,6 +108,8 @@ function resolveAnnounceOrigin(
}
async function sendAnnounce(item: AnnounceQueueItem) {
const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey);
const requesterIsSubagent = requesterDepth >= 1;
const origin = item.origin;
const threadId =
origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined;
@@ -122,15 +118,14 @@ async function sendAnnounce(item: AnnounceQueueItem) {
params: {
sessionKey: item.sessionKey,
message: item.prompt,
channel: origin?.channel,
accountId: origin?.accountId,
to: origin?.to,
threadId,
deliver: true,
channel: requesterIsSubagent ? undefined : origin?.channel,
accountId: requesterIsSubagent ? undefined : origin?.accountId,
to: requesterIsSubagent ? undefined : origin?.to,
threadId: requesterIsSubagent ? undefined : threadId,
deliver: !requesterIsSubagent,
idempotencyKey: crypto.randomUUID(),
},
expectFinal: true,
timeoutMs: 60_000,
timeoutMs: 15_000,
});
}
@@ -219,74 +214,6 @@ async function maybeQueueSubagentAnnounce(params: {
return "none";
}
async function buildSubagentStatsLine(params: {
sessionKey: string;
startedAt?: number;
endedAt?: number;
}) {
const cfg = loadConfig();
const { entry, storePath } = await waitForSessionUsage({
sessionKey: params.sessionKey,
});
const sessionId = entry?.sessionId;
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
let transcriptPath: string | undefined;
if (sessionId && storePath) {
try {
transcriptPath = resolveSessionFilePath(sessionId, entry, {
agentId,
sessionsDir: path.dirname(storePath),
});
} catch {
transcriptPath = undefined;
}
}
const input = entry?.inputTokens;
const output = entry?.outputTokens;
const total =
entry?.totalTokens ??
(typeof input === "number" && typeof output === "number" ? input + output : undefined);
const runtimeMs =
typeof params.startedAt === "number" && typeof params.endedAt === "number"
? Math.max(0, params.endedAt - params.startedAt)
: undefined;
const provider = entry?.modelProvider;
const model = entry?.model;
const costConfig = resolveModelCost({ provider, model, config: cfg });
const cost =
costConfig && typeof input === "number" && typeof output === "number"
? (input * costConfig.input + output * costConfig.output) / 1_000_000
: undefined;
const parts: string[] = [];
const runtime = formatDurationCompact(runtimeMs);
parts.push(`runtime ${runtime ?? "n/a"}`);
if (typeof total === "number") {
const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a";
const outputText = typeof output === "number" ? formatTokenCount(output) : "n/a";
const totalText = formatTokenCount(total);
parts.push(`tokens ${totalText} (in ${inputText} / out ${outputText})`);
} else {
parts.push("tokens n/a");
}
const costText = formatUsd(cost);
if (costText) {
parts.push(`est ${costText}`);
}
parts.push(`sessionKey ${params.sessionKey}`);
if (sessionId) {
parts.push(`sessionId ${sessionId}`);
}
if (transcriptPath) {
parts.push(`transcript ${transcriptPath}`);
}
return `Stats: ${parts.join(" \u2022 ")}`;
}
function loadSessionEntryByKey(sessionKey: string) {
const cfg = loadConfig();
const agentId = resolveAgentIdFromSessionKey(sessionKey);
@@ -323,49 +250,85 @@ export function buildSubagentSystemPrompt(params: {
childSessionKey: string;
label?: string;
task?: string;
/** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */
childDepth?: number;
/** Config value: max allowed spawn depth. */
maxSpawnDepth?: number;
}) {
const taskText =
typeof params.task === "string" && params.task.trim()
? params.task.replace(/\s+/g, " ").trim()
: "{{TASK_DESCRIPTION}}";
const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1;
const maxSpawnDepth = typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : 1;
const canSpawn = childDepth < maxSpawnDepth;
const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent";
const lines = [
"# Subagent Context",
"",
"You are a **subagent** spawned by the main agent for a specific task.",
`You are a **subagent** spawned by the ${parentLabel} for a specific task.`,
"",
"## Your Role",
`- You were created to handle: ${taskText}`,
"- Complete this task. That's your entire purpose.",
"- You are NOT the main agent. Don't try to be.",
`- You are NOT the ${parentLabel}. Don't try to be.`,
"",
"## Rules",
"1. **Stay focused** - Do your assigned task, nothing else",
"2. **Complete the task** - Your final message will be automatically reported to the main agent",
`2. **Complete the task** - Your final message will be automatically reported to the ${parentLabel}`,
"3. **Don't initiate** - No heartbeats, no proactive actions, no side quests",
"4. **Be ephemeral** - You may be terminated after task completion. That's fine.",
"5. **Trust push-based completion** - Descendant results are auto-announced back to you; do not busy-poll for status.",
"",
"## Output Format",
"When complete, your final response should include:",
"- What you accomplished or found",
"- Any relevant details the main agent should know",
`- What you accomplished or found`,
`- Any relevant details the ${parentLabel} should know`,
"- Keep it concise but informative",
"",
"## What You DON'T Do",
"- NO user conversations (that's main agent's job)",
`- NO user conversations (that's ${parentLabel}'s job)`,
"- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel",
"- NO cron jobs or persistent state",
"- NO pretending to be the main agent",
"- Only use the `message` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the main agent deliver it",
`- NO pretending to be the ${parentLabel}`,
`- Only use the \`message\` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the ${parentLabel} deliver it`,
"",
];
if (canSpawn) {
lines.push(
"## Sub-Agent Spawning",
"You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.",
"Use the `subagents` tool to steer, kill, or do an on-demand status check for your spawned sub-agents.",
"Your sub-agents will announce their results back to you automatically (not to the main agent).",
"Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.",
"Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.",
"Coordinate their work and synthesize results before reporting back.",
"",
);
} else if (childDepth >= 2) {
lines.push(
"## Sub-Agent Spawning",
"You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.",
"",
);
}
lines.push(
"## Session Context",
params.label ? `- Label: ${params.label}` : undefined,
params.requesterSessionKey ? `- Requester session: ${params.requesterSessionKey}.` : undefined,
params.requesterOrigin?.channel
? `- Requester channel: ${params.requesterOrigin.channel}.`
: undefined,
`- Your session: ${params.childSessionKey}.`,
...[
params.label ? `- Label: ${params.label}` : undefined,
params.requesterSessionKey
? `- Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterOrigin?.channel
? `- Requester channel: ${params.requesterOrigin.channel}.`
: undefined,
`- Your session: ${params.childSessionKey}.`,
].filter((line): line is string => line !== undefined),
"",
].filter((line): line is string => line !== undefined);
);
return lines.join("\n");
}
@@ -376,6 +339,21 @@ export type SubagentRunOutcome = {
export type SubagentAnnounceType = "subagent task" | "cron job";
function buildAnnounceReplyInstruction(params: {
remainingActiveSubagentRuns: number;
requesterIsSubagent: boolean;
announceType: SubagentAnnounceType;
}): string {
if (params.remainingActiveSubagentRuns > 0) {
const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs";
return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`;
}
if (params.requesterIsSubagent) {
return "Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: NO_REPLY.";
}
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.`;
}
export async function runSubagentAnnounceFlow(params: {
childSessionKey: string;
childRunId: string;
@@ -396,7 +374,8 @@ export async function runSubagentAnnounceFlow(params: {
let didAnnounce = false;
let shouldDeleteChildSession = params.cleanup === "delete";
try {
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
let targetRequesterSessionKey = params.requesterSessionKey;
let targetRequesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
const childSessionId = (() => {
const entry = loadSessionEntryByKey(params.childSessionKey);
return typeof entry?.sessionId === "string" && entry.sessionId.trim()
@@ -478,12 +457,19 @@ export async function runSubagentAnnounceFlow(params: {
outcome = { status: "unknown" };
}
// Build stats
const statsLine = await buildSubagentStatsLine({
sessionKey: params.childSessionKey,
startedAt: params.startedAt,
endedAt: params.endedAt,
});
let activeChildDescendantRuns = 0;
try {
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
} catch {
// Best-effort only; fall back to direct announce behavior when unavailable.
}
if (activeChildDescendantRuns > 0) {
// The finished run still has active descendant subagents. Defer announcing
// this run until descendants settle so we avoid posting in-progress updates.
shouldDeleteChildSession = false;
return false;
}
// Build status label
const statusLabel =
@@ -498,24 +484,70 @@ export async function runSubagentAnnounceFlow(params: {
// Build instructional message for main agent
const announceType = params.announceType ?? "subagent task";
const taskLabel = params.label || params.task || "task";
const triggerMessage = [
`A ${announceType} "${taskLabel}" just ${statusLabel}.`,
const announceSessionId = childSessionId || "unknown";
const findings = reply || "(no output)";
let triggerMessage = "";
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
let requesterIsSubagent = requesterDepth >= 1;
// If the requester subagent has already finished, bubble the announce to its
// requester (typically main) so descendant completion is not silently lost.
if (requesterIsSubagent) {
const { isSubagentSessionRunActive, resolveRequesterForChildSession } =
await import("./subagent-registry.js");
if (!isSubagentSessionRunActive(targetRequesterSessionKey)) {
const fallback = resolveRequesterForChildSession(targetRequesterSessionKey);
if (!fallback?.requesterSessionKey) {
// Without a requester fallback we cannot safely deliver this nested
// completion. Keep cleanup retryable so a later registry restore can
// recover and re-announce instead of silently dropping the result.
shouldDeleteChildSession = false;
return false;
}
targetRequesterSessionKey = fallback.requesterSessionKey;
targetRequesterOrigin =
normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin;
requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
requesterIsSubagent = requesterDepth >= 1;
}
}
let remainingActiveSubagentRuns = 0;
try {
const { countActiveDescendantRuns } = await import("./subagent-registry.js");
remainingActiveSubagentRuns = Math.max(
0,
countActiveDescendantRuns(targetRequesterSessionKey),
);
} catch {
// Best-effort only; fall back to default announce instructions when unavailable.
}
const replyInstruction = buildAnnounceReplyInstruction({
remainingActiveSubagentRuns,
requesterIsSubagent,
announceType,
});
const statsLine = await buildCompactAnnounceStatsLine({
sessionKey: params.childSessionKey,
startedAt: params.startedAt,
endedAt: params.endedAt,
});
triggerMessage = [
`[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`,
"",
"Findings:",
reply || "(no output)",
"Result:",
findings,
"",
statsLine,
"",
"Summarize this naturally for the user. Keep it brief (1-2 sentences). Flow it into the conversation naturally.",
`Do not mention technical details like tokens, stats, or that this was a ${announceType}.`,
"You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).",
replyInstruction,
].join("\n");
const queued = await maybeQueueSubagentAnnounce({
requesterSessionKey: params.requesterSessionKey,
requesterSessionKey: targetRequesterSessionKey,
triggerMessage,
summaryLine: taskLabel,
requesterOrigin,
requesterOrigin: targetRequesterOrigin,
});
if (queued === "steered") {
didAnnounce = true;
@@ -526,29 +558,30 @@ export async function runSubagentAnnounceFlow(params: {
return true;
}
// Send to main agent - it will respond in its own voice
let directOrigin = requesterOrigin;
if (!directOrigin) {
const { entry } = loadRequesterSessionEntry(params.requesterSessionKey);
// Send to the requester session. For nested subagents this is an internal
// follow-up injection (deliver=false) so the orchestrator receives it.
let directOrigin = targetRequesterOrigin;
if (!requesterIsSubagent && !directOrigin) {
const { entry } = loadRequesterSessionEntry(targetRequesterSessionKey);
directOrigin = deliveryContextFromSession(entry);
}
await callGateway({
method: "agent",
params: {
sessionKey: params.requesterSessionKey,
sessionKey: targetRequesterSessionKey,
message: triggerMessage,
deliver: true,
channel: directOrigin?.channel,
accountId: directOrigin?.accountId,
to: directOrigin?.to,
deliver: !requesterIsSubagent,
channel: requesterIsSubagent ? undefined : directOrigin?.channel,
accountId: requesterIsSubagent ? undefined : directOrigin?.accountId,
to: requesterIsSubagent ? undefined : directOrigin?.to,
threadId:
directOrigin?.threadId != null && directOrigin.threadId !== ""
!requesterIsSubagent && directOrigin?.threadId != null && directOrigin.threadId !== ""
? String(directOrigin.threadId)
: undefined,
idempotencyKey: crypto.randomUUID(),
},
expectFinal: true,
timeoutMs: 60_000,
timeoutMs: 15_000,
});
didAnnounce = true;

View File

@@ -0,0 +1,87 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
describe("getSubagentDepthFromSessionStore", () => {
it("uses spawnDepth from the session store when available", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: { spawnDepth: 2 },
},
});
expect(depth).toBe(2);
});
it("derives depth from spawnedBy ancestry when spawnDepth is missing", () => {
const key1 = "agent:main:subagent:one";
const key2 = "agent:main:subagent:two";
const key3 = "agent:main:subagent:three";
const depth = getSubagentDepthFromSessionStore(key3, {
store: {
[key1]: { spawnedBy: "agent:main:main" },
[key2]: { spawnedBy: key1 },
[key3]: { spawnedBy: key2 },
},
});
expect(depth).toBe(3);
});
it("resolves depth when caller is identified by sessionId", () => {
const key1 = "agent:main:subagent:one";
const key2 = "agent:main:subagent:two";
const key3 = "agent:main:subagent:three";
const depth = getSubagentDepthFromSessionStore("subagent-three-session", {
store: {
[key1]: { sessionId: "subagent-one-session", spawnedBy: "agent:main:main" },
[key2]: { sessionId: "subagent-two-session", spawnedBy: key1 },
[key3]: { sessionId: "subagent-three-session", spawnedBy: key2 },
},
});
expect(depth).toBe(3);
});
it("resolves prefixed store keys when caller key omits the agent prefix", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-"));
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const prefixedKey = "agent:main:subagent:flat";
const storePath = storeTemplate.replaceAll("{agentId}", "main");
fs.writeFileSync(
storePath,
JSON.stringify(
{
[prefixedKey]: {
sessionId: "subagent-flat",
updatedAt: Date.now(),
spawnDepth: 2,
},
},
null,
2,
),
"utf-8",
);
const depth = getSubagentDepthFromSessionStore("subagent:flat", {
cfg: {
session: {
store: storeTemplate,
},
},
});
expect(depth).toBe(2);
});
it("falls back to session-key segment counting when metadata is missing", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: {},
},
});
expect(depth).toBe(1);
});
});

View File

@@ -0,0 +1,176 @@
import JSON5 from "json5";
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { resolveDefaultAgentId } from "./agent-scope.js";
type SessionDepthEntry = {
sessionId?: unknown;
spawnDepth?: unknown;
spawnedBy?: unknown;
};
function normalizeSpawnDepth(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isInteger(value) && value >= 0 ? value : undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const numeric = Number(trimmed);
return Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined;
}
return undefined;
}
function normalizeSessionKey(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function readSessionStore(storePath: string): Record<string, SessionDepthEntry> {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, SessionDepthEntry>;
}
} catch {
// ignore missing/invalid stores
}
return {};
}
function buildKeyCandidates(rawKey: string, cfg?: OpenClawConfig): string[] {
if (!cfg) {
return [rawKey];
}
if (rawKey === "global" || rawKey === "unknown") {
return [rawKey];
}
if (parseAgentSessionKey(rawKey)) {
return [rawKey];
}
const defaultAgentId = resolveDefaultAgentId(cfg);
const prefixed = `agent:${defaultAgentId}:${rawKey}`;
return prefixed === rawKey ? [rawKey] : [rawKey, prefixed];
}
function findEntryBySessionId(
store: Record<string, SessionDepthEntry>,
sessionId: string,
): SessionDepthEntry | undefined {
const normalizedSessionId = normalizeSessionKey(sessionId);
if (!normalizedSessionId) {
return undefined;
}
for (const entry of Object.values(store)) {
const candidateSessionId = normalizeSessionKey(entry?.sessionId);
if (candidateSessionId && candidateSessionId === normalizedSessionId) {
return entry;
}
}
return undefined;
}
function resolveEntryForSessionKey(params: {
sessionKey: string;
cfg?: OpenClawConfig;
store?: Record<string, SessionDepthEntry>;
cache: Map<string, Record<string, SessionDepthEntry>>;
}): SessionDepthEntry | undefined {
const candidates = buildKeyCandidates(params.sessionKey, params.cfg);
if (params.store) {
for (const key of candidates) {
const entry = params.store[key];
if (entry) {
return entry;
}
}
return findEntryBySessionId(params.store, params.sessionKey);
}
if (!params.cfg) {
return undefined;
}
for (const key of candidates) {
const parsed = parseAgentSessionKey(key);
if (!parsed?.agentId) {
continue;
}
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed.agentId });
let store = params.cache.get(storePath);
if (!store) {
store = readSessionStore(storePath);
params.cache.set(storePath, store);
}
const entry = store[key] ?? findEntryBySessionId(store, params.sessionKey);
if (entry) {
return entry;
}
}
return undefined;
}
export function getSubagentDepthFromSessionStore(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: Record<string, SessionDepthEntry>;
},
): number {
const raw = (sessionKey ?? "").trim();
const fallbackDepth = getSubagentDepth(raw);
if (!raw) {
return fallbackDepth;
}
const cache = new Map<string, Record<string, SessionDepthEntry>>();
const visited = new Set<string>();
const depthFromStore = (key: string): number | undefined => {
const normalizedKey = normalizeSessionKey(key);
if (!normalizedKey) {
return undefined;
}
if (visited.has(normalizedKey)) {
return undefined;
}
visited.add(normalizedKey);
const entry = resolveEntryForSessionKey({
sessionKey: normalizedKey,
cfg: opts?.cfg,
store: opts?.store,
cache,
});
const storedDepth = normalizeSpawnDepth(entry?.spawnDepth);
if (storedDepth !== undefined) {
return storedDepth;
}
const spawnedBy = normalizeSessionKey(entry?.spawnedBy);
if (!spawnedBy) {
return undefined;
}
const parentDepth = depthFromStore(spawnedBy);
if (parentDepth !== undefined) {
return parentDepth + 1;
}
return getSubagentDepth(spawnedBy) + 1;
};
return depthFromStore(raw) ?? fallbackDepth;
}

View File

@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async () => ({
status: "ok",
startedAt: 111,
endedAt: 222,
})),
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: vi.fn(() => noop),
}));
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(async () => true),
buildSubagentSystemPrompt: vi.fn(() => "test prompt"),
}));
describe("subagent registry nested agent tracking", () => {
afterEach(async () => {
const mod = await import("./subagent-registry.js");
mod.resetSubagentRegistryForTests();
});
it("listSubagentRunsForRequester returns children of the requesting session", async () => {
const { registerSubagentRun, listSubagentRunsForRequester } =
await import("./subagent-registry.js");
// Main agent spawns a depth-1 orchestrator
registerSubagentRun({
runId: "run-orch",
childSessionKey: "agent:main:subagent:orch-uuid",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate something",
cleanup: "keep",
label: "orchestrator",
});
// Depth-1 orchestrator spawns a depth-2 leaf
registerSubagentRun({
runId: "run-leaf",
childSessionKey: "agent:main:subagent:orch-uuid:subagent:leaf-uuid",
requesterSessionKey: "agent:main:subagent:orch-uuid",
requesterDisplayKey: "subagent:orch-uuid",
task: "do leaf work",
cleanup: "keep",
label: "leaf",
});
// Main sees its direct child (the orchestrator)
const mainRuns = listSubagentRunsForRequester("agent:main:main");
expect(mainRuns).toHaveLength(1);
expect(mainRuns[0].runId).toBe("run-orch");
// Orchestrator sees its direct child (the leaf)
const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch-uuid");
expect(orchRuns).toHaveLength(1);
expect(orchRuns[0].runId).toBe("run-leaf");
// Leaf has no children
const leafRuns = listSubagentRunsForRequester(
"agent:main:subagent:orch-uuid:subagent:leaf-uuid",
);
expect(leafRuns).toHaveLength(0);
});
it("announce uses requesterSessionKey to route to the correct parent", async () => {
const { registerSubagentRun } = await import("./subagent-registry.js");
// Register a sub-sub-agent whose parent is a sub-agent
registerSubagentRun({
runId: "run-subsub",
childSessionKey: "agent:main:subagent:orch:subagent:child",
requesterSessionKey: "agent:main:subagent:orch",
requesterDisplayKey: "subagent:orch",
task: "nested task",
cleanup: "keep",
label: "nested-leaf",
});
// When announce fires for the sub-sub-agent, it should target the sub-agent (depth-1),
// NOT the main session. The registry entry's requesterSessionKey ensures this.
// We verify the registry entry has the correct requesterSessionKey.
const { listSubagentRunsForRequester } = await import("./subagent-registry.js");
const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch");
expect(orchRuns).toHaveLength(1);
expect(orchRuns[0].requesterSessionKey).toBe("agent:main:subagent:orch");
expect(orchRuns[0].childSessionKey).toBe("agent:main:subagent:orch:subagent:child");
});
it("countActiveRunsForSession only counts active children of the specific session", async () => {
const { registerSubagentRun, countActiveRunsForSession } =
await import("./subagent-registry.js");
// Main spawns orchestrator (active)
registerSubagentRun({
runId: "run-orch-active",
childSessionKey: "agent:main:subagent:orch1",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate",
cleanup: "keep",
});
// Orchestrator spawns two leaves
registerSubagentRun({
runId: "run-leaf-1",
childSessionKey: "agent:main:subagent:orch1:subagent:leaf1",
requesterSessionKey: "agent:main:subagent:orch1",
requesterDisplayKey: "subagent:orch1",
task: "leaf 1",
cleanup: "keep",
});
registerSubagentRun({
runId: "run-leaf-2",
childSessionKey: "agent:main:subagent:orch1:subagent:leaf2",
requesterSessionKey: "agent:main:subagent:orch1",
requesterDisplayKey: "subagent:orch1",
task: "leaf 2",
cleanup: "keep",
});
// Main has 1 active child
expect(countActiveRunsForSession("agent:main:main")).toBe(1);
// Orchestrator has 2 active children
expect(countActiveRunsForSession("agent:main:subagent:orch1")).toBe(2);
});
it("countActiveDescendantRuns traverses through ended parents", async () => {
const { addSubagentRunForTests, countActiveDescendantRuns } =
await import("./subagent-registry.js");
addSubagentRunForTests({
runId: "run-parent-ended",
childSessionKey: "agent:main:subagent:orch-ended",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
cleanupHandled: false,
});
addSubagentRunForTests({
runId: "run-leaf-active",
childSessionKey: "agent:main:subagent:orch-ended:subagent:leaf",
requesterSessionKey: "agent:main:subagent:orch-ended",
requesterDisplayKey: "orch-ended",
task: "leaf",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
cleanupHandled: false,
});
expect(countActiveDescendantRuns("agent:main:main")).toBe(1);
expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1);
});
});

View File

@@ -274,4 +274,12 @@ describe("subagent registry persistence", () => {
};
expect(afterSecond.runs?.["run-4"]).toBeUndefined();
});
it("uses isolated temp state when OPENCLAW_STATE_DIR is unset in tests", async () => {
delete process.env.OPENCLAW_STATE_DIR;
vi.resetModules();
const { resolveSubagentRegistryPath } = await import("./subagent-registry.store.js");
const registryPath = resolveSubagentRegistryPath();
expect(registryPath).toContain(path.join(os.tmpdir(), "openclaw-test-state"));
});
});

View File

@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
let lifecycleHandler:
| ((evt: { stream?: string; runId: string; data?: { phase?: string } }) => void)
| undefined;
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
}),
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => {
lifecycleHandler = handler;
return noop;
}),
}));
const announceSpy = vi.fn(async () => true);
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: (...args: unknown[]) => announceSpy(...args),
}));
describe("subagent registry steer restarts", () => {
afterEach(async () => {
announceSpy.mockClear();
lifecycleHandler = undefined;
const mod = await import("./subagent-registry.js");
mod.resetSubagentRegistryForTests();
});
it("suppresses announce for interrupted runs and only announces the replacement run", async () => {
const mod = await import("./subagent-registry.js");
mod.registerSubagentRun({
runId: "run-old",
childSessionKey: "agent:main:subagent:steer",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "initial task",
cleanup: "keep",
});
const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
expect(previous?.runId).toBe("run-old");
const marked = mod.markSubagentRunForSteerRestart("run-old");
expect(marked).toBe(true);
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-old",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(announceSpy).not.toHaveBeenCalled();
const replaced = mod.replaceSubagentRunAfterSteer({
previousRunId: "run-old",
nextRunId: "run-new",
fallback: previous,
});
expect(replaced).toBe(true);
const runs = mod.listSubagentRunsForRequester("agent:main:main");
expect(runs).toHaveLength(1);
expect(runs[0].runId).toBe("run-new");
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-new",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(announceSpy).toHaveBeenCalledTimes(1);
const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string };
expect(announce.childRunId).toBe("run-new");
});
it("restores announce for a finished run when steer replacement dispatch fails", async () => {
const mod = await import("./subagent-registry.js");
mod.registerSubagentRun({
runId: "run-failed-restart",
childSessionKey: "agent:main:subagent:failed-restart",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "initial task",
cleanup: "keep",
});
expect(mod.markSubagentRunForSteerRestart("run-failed-restart")).toBe(true);
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-failed-restart",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(announceSpy).not.toHaveBeenCalled();
expect(mod.clearSubagentRunSteerRestart("run-failed-restart")).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(announceSpy).toHaveBeenCalledTimes(1);
const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string };
expect(announce.childRunId).toBe("run-failed-restart");
});
it("marks killed runs terminated and inactive", async () => {
const mod = await import("./subagent-registry.js");
const childSessionKey = "agent:main:subagent:killed";
mod.registerSubagentRun({
runId: "run-killed",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "kill me",
cleanup: "keep",
});
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(true);
const updated = mod.markSubagentRunTerminated({
childSessionKey,
reason: "manual kill",
});
expect(updated).toBe(1);
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false);
const run = mod.listSubagentRunsForRequester("agent:main:main")[0];
expect(run?.outcome).toEqual({ status: "error", error: "manual kill" });
expect(run?.cleanupHandled).toBe(true);
expect(typeof run?.cleanupCompletedAt).toBe("number");
});
it("retries deferred parent cleanup after a descendant announces", async () => {
const mod = await import("./subagent-registry.js");
let parentAttempts = 0;
announceSpy.mockImplementation(async (params: unknown) => {
const typed = params as { childRunId?: string };
if (typed.childRunId === "run-parent") {
parentAttempts += 1;
return parentAttempts >= 2;
}
return true;
});
mod.registerSubagentRun({
runId: "run-parent",
childSessionKey: "agent:main:subagent:parent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "parent task",
cleanup: "keep",
});
mod.registerSubagentRun({
runId: "run-child",
childSessionKey: "agent:main:subagent:parent:subagent:child",
requesterSessionKey: "agent:main:subagent:parent",
requesterDisplayKey: "parent",
task: "child task",
cleanup: "keep",
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-parent",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-child",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
const childRunIds = announceSpy.mock.calls.map(
(call) => (call[0] as { childRunId?: string }).childRunId,
);
expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2);
expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1);
});
});

View File

@@ -1,3 +1,4 @@
import os from "node:os";
import path from "node:path";
import type { SubagentRunRecord } from "./subagent-registry.js";
import { resolveStateDir } from "../config/paths.js";
@@ -29,8 +30,19 @@ type LegacySubagentRunRecord = PersistedSubagentRunRecord & {
requesterAccountId?: unknown;
};
function resolveSubagentStateDir(env: NodeJS.ProcessEnv = process.env): string {
const explicit = env.OPENCLAW_STATE_DIR?.trim();
if (explicit) {
return resolveStateDir(env);
}
if (env.VITEST || env.NODE_ENV === "test") {
return path.join(os.tmpdir(), "openclaw-test-state", String(process.pid));
}
return resolveStateDir(env);
}
export function resolveSubagentRegistryPath(): string {
return path.join(resolveStateDir(), "subagents", "runs.json");
return path.join(resolveSubagentStateDir(process.env), "subagents", "runs.json");
}
export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {

View File

@@ -19,6 +19,8 @@ export type SubagentRunRecord = {
task: string;
cleanup: "delete" | "keep";
label?: string;
model?: string;
runTimeoutSeconds?: number;
createdAt: number;
startedAt?: number;
endedAt?: number;
@@ -26,6 +28,7 @@ export type SubagentRunRecord = {
archiveAtMs?: number;
cleanupCompletedAt?: number;
cleanupHandled?: boolean;
suppressAnnounceReason?: "steer-restart" | "killed";
};
const subagentRuns = new Map<string, SubagentRunRecord>();
@@ -46,29 +49,8 @@ function persistSubagentRuns() {
const resumedRuns = new Set<string>();
function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecord): boolean {
if (!beginSubagentCleanup(runId)) {
return false;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
cleanup: entry.cleanup,
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
}).then((didAnnounce) => {
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
});
return true;
function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) {
return entry?.suppressAnnounceReason === "steer-restart";
}
function resumeSubagentRun(runId: string) {
@@ -84,16 +66,38 @@ function resumeSubagentRun(runId: string) {
}
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
if (!startSubagentAnnounceCleanupFlow(runId, entry)) {
if (suppressAnnounceForSteerRestart(entry)) {
resumedRuns.add(runId);
return;
}
if (!beginSubagentCleanup(runId)) {
return;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
cleanup: entry.cleanup,
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
}).then((didAnnounce) => {
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
});
resumedRuns.add(runId);
return;
}
// Wait for completion again after restart.
const cfg = loadConfig();
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, undefined);
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, entry.runTimeoutSeconds);
void waitForSubagentCompletion(runId, waitTimeoutMs);
resumedRuns.add(runId);
}
@@ -144,7 +148,7 @@ function resolveSubagentWaitTimeoutMs(
cfg: ReturnType<typeof loadConfig>,
runTimeoutSeconds?: number,
) {
return resolveAgentTimeoutMs({ cfg, overrideSeconds: runTimeoutSeconds });
return resolveAgentTimeoutMs({ cfg, overrideSeconds: runTimeoutSeconds ?? 0 });
}
function startSweeper() {
@@ -229,7 +233,31 @@ function ensureListener() {
}
persistSubagentRuns();
void startSubagentAnnounceCleanupFlow(evt.runId, entry);
if (suppressAnnounceForSteerRestart(entry)) {
return;
}
if (!beginSubagentCleanup(evt.runId)) {
return;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
cleanup: entry.cleanup,
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
}).then((didAnnounce) => {
finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce);
});
});
}
@@ -241,16 +269,38 @@ function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didA
if (!didAnnounce) {
// Allow retry on the next wake if announce was deferred or failed.
entry.cleanupHandled = false;
resumedRuns.delete(runId);
persistSubagentRuns();
return;
}
if (cleanup === "delete") {
subagentRuns.delete(runId);
persistSubagentRuns();
retryDeferredCompletedAnnounces(runId);
return;
}
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns();
retryDeferredCompletedAnnounces(runId);
}
function retryDeferredCompletedAnnounces(excludeRunId?: string) {
for (const [runId, entry] of subagentRuns.entries()) {
if (excludeRunId && runId === excludeRunId) {
continue;
}
if (typeof entry.endedAt !== "number") {
continue;
}
if (entry.cleanupCompletedAt || entry.cleanupHandled) {
continue;
}
if (suppressAnnounceForSteerRestart(entry)) {
continue;
}
resumedRuns.delete(runId);
resumeSubagentRun(runId);
}
}
function beginSubagentCleanup(runId: string) {
@@ -269,6 +319,99 @@ function beginSubagentCleanup(runId: string) {
return true;
}
export function markSubagentRunForSteerRestart(runId: string) {
const key = runId.trim();
if (!key) {
return false;
}
const entry = subagentRuns.get(key);
if (!entry) {
return false;
}
if (entry.suppressAnnounceReason === "steer-restart") {
return true;
}
entry.suppressAnnounceReason = "steer-restart";
persistSubagentRuns();
return true;
}
export function clearSubagentRunSteerRestart(runId: string) {
const key = runId.trim();
if (!key) {
return false;
}
const entry = subagentRuns.get(key);
if (!entry) {
return false;
}
if (entry.suppressAnnounceReason !== "steer-restart") {
return true;
}
entry.suppressAnnounceReason = undefined;
persistSubagentRuns();
// If the interrupted run already finished while suppression was active, retry
// cleanup now so completion output is not lost when restart dispatch fails.
resumedRuns.delete(key);
if (typeof entry.endedAt === "number" && !entry.cleanupCompletedAt) {
resumeSubagentRun(key);
}
return true;
}
export function replaceSubagentRunAfterSteer(params: {
previousRunId: string;
nextRunId: string;
fallback?: SubagentRunRecord;
runTimeoutSeconds?: number;
}) {
const previousRunId = params.previousRunId.trim();
const nextRunId = params.nextRunId.trim();
if (!previousRunId || !nextRunId) {
return false;
}
const previous = subagentRuns.get(previousRunId);
const source = previous ?? params.fallback;
if (!source) {
return false;
}
if (previousRunId !== nextRunId) {
subagentRuns.delete(previousRunId);
resumedRuns.delete(previousRunId);
}
const now = Date.now();
const cfg = loadConfig();
const archiveAfterMs = resolveArchiveAfterMs(cfg);
const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined;
const runTimeoutSeconds = params.runTimeoutSeconds ?? source.runTimeoutSeconds ?? 0;
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds);
const next: SubagentRunRecord = {
...source,
runId: nextRunId,
startedAt: now,
endedAt: undefined,
outcome: undefined,
cleanupCompletedAt: undefined,
cleanupHandled: false,
suppressAnnounceReason: undefined,
archiveAtMs,
runTimeoutSeconds,
};
subagentRuns.set(nextRunId, next);
ensureListener();
persistSubagentRuns();
if (archiveAtMs) {
startSweeper();
}
void waitForSubagentCompletion(nextRunId, waitTimeoutMs);
return true;
}
export function registerSubagentRun(params: {
runId: string;
childSessionKey: string;
@@ -278,13 +421,15 @@ export function registerSubagentRun(params: {
task: string;
cleanup: "delete" | "keep";
label?: string;
model?: string;
runTimeoutSeconds?: number;
}) {
const now = Date.now();
const cfg = loadConfig();
const archiveAfterMs = resolveArchiveAfterMs(cfg);
const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined;
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, params.runTimeoutSeconds);
const runTimeoutSeconds = params.runTimeoutSeconds ?? 0;
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds);
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
subagentRuns.set(params.runId, {
runId: params.runId,
@@ -295,6 +440,8 @@ export function registerSubagentRun(params: {
task: params.task,
cleanup: params.cleanup,
label: params.label,
model: params.model,
runTimeoutSeconds,
createdAt: now,
startedAt: now,
archiveAtMs,
@@ -357,7 +504,30 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
if (mutated) {
persistSubagentRuns();
}
void startSubagentAnnounceCleanupFlow(runId, entry);
if (suppressAnnounceForSteerRestart(entry)) {
return;
}
if (!beginSubagentCleanup(runId)) {
return;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
void runSubagentAnnounceFlow({
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
cleanup: entry.cleanup,
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
}).then((didAnnounce) => {
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
});
} catch {
// ignore
}
@@ -381,7 +551,6 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
export function addSubagentRunForTests(entry: SubagentRunRecord) {
subagentRuns.set(entry.runId, entry);
persistSubagentRuns();
}
export function releaseSubagentRun(runId: string) {
@@ -394,6 +563,122 @@ export function releaseSubagentRun(runId: string) {
}
}
function findRunIdsByChildSessionKey(childSessionKey: string): string[] {
const key = childSessionKey.trim();
if (!key) {
return [];
}
const runIds: string[] = [];
for (const [runId, entry] of subagentRuns.entries()) {
if (entry.childSessionKey === key) {
runIds.push(runId);
}
}
return runIds;
}
function getRunsSnapshotForRead(): Map<string, SubagentRunRecord> {
const merged = new Map<string, SubagentRunRecord>();
const shouldReadDisk = !(process.env.VITEST || process.env.NODE_ENV === "test");
if (shouldReadDisk) {
try {
// Registry state is persisted to disk so other worker processes (for
// example cron runners) can observe active children spawned elsewhere.
for (const [runId, entry] of loadSubagentRegistryFromDisk().entries()) {
merged.set(runId, entry);
}
} catch {
// Ignore disk read failures and fall back to local memory state.
}
}
for (const [runId, entry] of subagentRuns.entries()) {
merged.set(runId, entry);
}
return merged;
}
export function resolveRequesterForChildSession(childSessionKey: string): {
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
} | null {
const key = childSessionKey.trim();
if (!key) {
return null;
}
let best: SubagentRunRecord | undefined;
for (const entry of getRunsSnapshotForRead().values()) {
if (entry.childSessionKey !== key) {
continue;
}
if (!best || entry.createdAt > best.createdAt) {
best = entry;
}
}
if (!best) {
return null;
}
return {
requesterSessionKey: best.requesterSessionKey,
requesterOrigin: normalizeDeliveryContext(best.requesterOrigin),
};
}
export function isSubagentSessionRunActive(childSessionKey: string): boolean {
const runIds = findRunIdsByChildSessionKey(childSessionKey);
for (const runId of runIds) {
const entry = subagentRuns.get(runId);
if (!entry) {
continue;
}
if (typeof entry.endedAt !== "number") {
return true;
}
}
return false;
}
export function markSubagentRunTerminated(params: {
runId?: string;
childSessionKey?: string;
reason?: string;
}): number {
const runIds = new Set<string>();
if (typeof params.runId === "string" && params.runId.trim()) {
runIds.add(params.runId.trim());
}
if (typeof params.childSessionKey === "string" && params.childSessionKey.trim()) {
for (const runId of findRunIdsByChildSessionKey(params.childSessionKey)) {
runIds.add(runId);
}
}
if (runIds.size === 0) {
return 0;
}
const now = Date.now();
const reason = params.reason?.trim() || "killed";
let updated = 0;
for (const runId of runIds) {
const entry = subagentRuns.get(runId);
if (!entry) {
continue;
}
if (typeof entry.endedAt === "number") {
continue;
}
entry.endedAt = now;
entry.outcome = { status: "error", error: reason };
entry.cleanupHandled = true;
entry.cleanupCompletedAt = now;
entry.suppressAnnounceReason = "killed";
updated += 1;
}
if (updated > 0) {
persistSubagentRuns();
}
return updated;
}
export function listSubagentRunsForRequester(requesterSessionKey: string): SubagentRunRecord[] {
const key = requesterSessionKey.trim();
if (!key) {
@@ -402,6 +687,86 @@ export function listSubagentRunsForRequester(requesterSessionKey: string): Subag
return [...subagentRuns.values()].filter((entry) => entry.requesterSessionKey === key);
}
export function countActiveRunsForSession(requesterSessionKey: string): number {
const key = requesterSessionKey.trim();
if (!key) {
return 0;
}
let count = 0;
for (const entry of getRunsSnapshotForRead().values()) {
if (entry.requesterSessionKey !== key) {
continue;
}
if (typeof entry.endedAt === "number") {
continue;
}
count += 1;
}
return count;
}
export function countActiveDescendantRuns(rootSessionKey: string): number {
const root = rootSessionKey.trim();
if (!root) {
return 0;
}
const runs = getRunsSnapshotForRead();
const pending = [root];
const visited = new Set<string>([root]);
let count = 0;
while (pending.length > 0) {
const requester = pending.shift();
if (!requester) {
continue;
}
for (const entry of runs.values()) {
if (entry.requesterSessionKey !== requester) {
continue;
}
if (typeof entry.endedAt !== "number") {
count += 1;
}
const childKey = entry.childSessionKey.trim();
if (!childKey || visited.has(childKey)) {
continue;
}
visited.add(childKey);
pending.push(childKey);
}
}
return count;
}
export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] {
const root = rootSessionKey.trim();
if (!root) {
return [];
}
const runs = getRunsSnapshotForRead();
const pending = [root];
const visited = new Set<string>([root]);
const descendants: SubagentRunRecord[] = [];
while (pending.length > 0) {
const requester = pending.shift();
if (!requester) {
continue;
}
for (const entry of runs.values()) {
if (entry.requesterSessionKey !== requester) {
continue;
}
descendants.push(entry);
const childKey = entry.childSessionKey.trim();
if (!childKey || visited.has(childKey)) {
continue;
}
visited.add(childKey);
pending.push(childKey);
}
}
return descendants;
}
export function initSubagentRegistry() {
restoreSubagentRunsOnce();
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildSubagentSystemPrompt } from "./subagent-announce.js";
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
describe("buildAgentSystemPrompt", () => {
@@ -103,6 +104,26 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Do not invent commands");
});
it("marks system message blocks as internal and not user-visible", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("`[System Message] ...` blocks are internal context");
expect(prompt).toContain("are not user-visible by default");
expect(prompt).toContain("reports completed cron/subagent work");
expect(prompt).toContain("rewrite it in your normal assistant voice");
});
it("guides subagent workflows to avoid polling loops", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("Completion is push-based: it will auto-announce when done.");
expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop");
});
it("lists available tools when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -450,3 +471,81 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode.");
});
});
describe("buildSubagentSystemPrompt", () => {
it("includes sub-agent spawning guidance for depth-1 orchestrator when maxSpawnDepth >= 2", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 2,
});
expect(prompt).toContain("## Sub-Agent Spawning");
expect(prompt).toContain("You CAN spawn your own sub-agents");
expect(prompt).toContain("sessions_spawn");
expect(prompt).toContain("`subagents` tool");
expect(prompt).toContain("announce their results back to you automatically");
expect(prompt).toContain("Do NOT repeatedly poll `subagents list`");
});
it("does not include spawning guidance for depth-1 leaf when maxSpawnDepth == 1", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 1,
});
expect(prompt).not.toContain("## Sub-Agent Spawning");
expect(prompt).not.toContain("You CAN spawn");
});
it("includes leaf worker note for depth-2 sub-sub-agents", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc:subagent:def",
task: "leaf task",
childDepth: 2,
maxSpawnDepth: 2,
});
expect(prompt).toContain("## Sub-Agent Spawning");
expect(prompt).toContain("leaf worker");
expect(prompt).toContain("CANNOT spawn further sub-agents");
});
it("uses 'parent orchestrator' label for depth-2 agents", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc:subagent:def",
task: "leaf task",
childDepth: 2,
maxSpawnDepth: 2,
});
expect(prompt).toContain("spawned by the parent orchestrator");
expect(prompt).toContain("reported to the parent orchestrator");
});
it("uses 'main agent' label for depth-1 agents", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "orchestrator task",
childDepth: 1,
maxSpawnDepth: 2,
});
expect(prompt).toContain("spawned by the main agent");
expect(prompt).toContain("reported to the main agent");
});
it("defaults to depth 1 and maxSpawnDepth 1 when not provided", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "basic task",
});
// Should not include spawning guidance (default maxSpawnDepth is 1, depth 1 is leaf)
expect(prompt).not.toContain("## Sub-Agent Spawning");
expect(prompt).toContain("spawned by the main agent");
});
});

View File

@@ -109,6 +109,9 @@ function buildMessagingSection(params: {
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Sub-agent orchestration → use subagents(action=list|steer|kill)",
"- `[System Message] ...` blocks are internal context and are not user-visible by default.",
"- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).",
"- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.",
params.availableTools.has("message")
? [
@@ -241,6 +244,7 @@ export function buildAgentSystemPrompt(params: {
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
subagents: "List, steer, or kill sub-agent runs for this requester session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
@@ -268,6 +272,7 @@ export function buildAgentSystemPrompt(params: {
"sessions_list",
"sessions_history",
"sessions_send",
"subagents",
"session_status",
"image",
];
@@ -403,6 +408,7 @@ export function buildAgentSystemPrompt(params: {
"- apply_patch: apply multi-file patches",
`- ${execToolName}: run shell commands (supports background via yieldMs/background)`,
`- ${processToolName}: manage background exec sessions`,
`- For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
"- browser: control OpenClaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
@@ -410,10 +416,12 @@ export function buildAgentSystemPrompt(params: {
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
"- subagents: list/steer/kill sub-agent runs",
'- session_status: show usage/time/model state and answer "what model are we using?"',
].join("\n"),
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
"Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).",
"",
"## Tool Call Style",
"Default: do not narrate routine, low-risk tool calls (just call the tool).",

View File

@@ -10,7 +10,6 @@ describe("tool display details", () => {
task: "double-message-bug-gpt",
label: 0,
runTimeoutSeconds: 0,
timeoutSeconds: 0,
},
}),
);

View File

@@ -267,10 +267,18 @@
"model",
"thinking",
"runTimeoutSeconds",
"cleanup",
"timeoutSeconds"
"cleanup"
]
},
"subagents": {
"emoji": "🤖",
"title": "Subagents",
"actions": {
"list": { "label": "list", "detailKeys": ["recentMinutes"] },
"kill": { "label": "kill", "detailKeys": ["target"] },
"steer": { "label": "steer", "detailKeys": ["target"] }
}
},
"session_status": {
"emoji": "📊",
"title": "Session Status",

View File

@@ -24,6 +24,7 @@ describe("tool-policy", () => {
const group = TOOL_GROUPS["group:openclaw"];
expect(group).toContain("browser");
expect(group).toContain("message");
expect(group).toContain("subagents");
expect(group).toContain("session_status");
});
});

View File

@@ -26,6 +26,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status",
],
// UI helpers
@@ -49,6 +50,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
"sessions_history",
"sessions_send",
"sessions_spawn",
"subagents",
"session_status",
"memory_search",
"memory_get",

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
import { readLatestAssistantReply } from "./agent-step.js";
describe("readLatestAssistantReply", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
it("returns the most recent assistant message when compaction markers trail history", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "All checks passed and changes were pushed." }],
},
{ role: "toolResult", content: [{ type: "text", text: "tool output" }] },
{ role: "system", content: [{ type: "text", text: "Compaction" }] },
],
});
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
expect(result).toBe("All checks passed and changes were pushed.");
expect(callGatewayMock).toHaveBeenCalledWith({
method: "chat.history",
params: { sessionKey: "agent:main:child", limit: 50 },
});
});
it("falls back to older assistant text when latest assistant has no text", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{ role: "assistant", content: [{ type: "text", text: "older output" }] },
{ role: "assistant", content: [] },
{ role: "system", content: [{ type: "text", text: "Compaction" }] },
],
});
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
expect(result).toBe("older output");
});
});

View File

@@ -13,8 +13,21 @@ export async function readLatestAssistantReply(params: {
params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
return last ? extractAssistantText(last) : undefined;
for (let i = filtered.length - 1; i >= 0; i -= 1) {
const candidate = filtered[i];
if (!candidate || typeof candidate !== "object") {
continue;
}
if ((candidate as { role?: unknown }).role !== "assistant") {
continue;
}
const text = extractAssistantText(candidate);
if (!text?.trim()) {
continue;
}
return text;
}
return undefined;
}
export async function runAgentStep(params: {

View File

@@ -40,4 +40,19 @@ describe("extractAssistantText", () => {
};
expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error");
});
it("keeps normal status text that mentions billing", () => {
const message = {
role: "assistant",
content: [
{
type: "text",
text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
},
],
};
expect(extractAssistantText(message)).toBe(
"Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
);
});
});

View File

@@ -5,17 +5,15 @@ import type { AnyAgentTool } from "./common.js";
import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { buildSubagentSystemPrompt } from "../subagent-announce.js";
import { registerSubagentRun } from "../subagent-registry.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js";
import { jsonResult, readStringParam } from "./common.js";
import {
resolveDisplaySessionKey,
@@ -30,8 +28,6 @@ const SessionsSpawnToolSchema = Type.Object({
model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: optionalStringEnum(["delete", "keep"] as const),
});
@@ -99,32 +95,18 @@ export function createSessionsSpawnTool(opts?: {
to: opts?.agentTo,
threadId: opts?.agentThreadId,
});
const runTimeoutSeconds = (() => {
const explicit =
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: undefined;
if (explicit !== undefined) {
return explicit;
}
const legacy =
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
? Math.max(0, Math.floor(params.timeoutSeconds))
: undefined;
return legacy ?? 0;
})();
// Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived
// by default and should not inherit the main agent 600s timeout.
const runTimeoutSeconds =
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: 0;
let modelWarning: string | undefined;
let modelApplied = false;
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterSessionKey = opts?.agentSessionKey;
if (typeof requesterSessionKey === "string" && isSubagentSessionKey(requesterSessionKey)) {
return jsonResult({
status: "forbidden",
error: "sessions_spawn is not allowed from sub-agent sessions",
});
}
const requesterInternalKey = requesterSessionKey
? resolveInternalSessionKey({
key: requesterSessionKey,
@@ -138,6 +120,24 @@ export function createSessionsSpawnTool(opts?: {
mainKey,
});
const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg });
const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
if (callerDepth >= maxSpawnDepth) {
return jsonResult({
status: "forbidden",
error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`,
});
}
const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5;
const activeChildren = countActiveRunsForSession(requesterInternalKey);
if (activeChildren >= maxChildren) {
return jsonResult({
status: "forbidden",
error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`,
});
}
const requesterAgentId = normalizeAgentId(
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
);
@@ -166,12 +166,19 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const childDepth = callerDepth + 1;
const spawnedByKey = requesterInternalKey;
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const runtimeDefaultModel = resolveDefaultModelForAgent({
cfg,
agentId: targetAgentId,
});
const resolvedModel =
normalizeModelSelection(modelOverride) ??
normalizeModelSelection(targetAgentConfig?.subagents?.model) ??
normalizeModelSelection(cfg.agents?.defaults?.subagents?.model);
normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ??
normalizeModelSelection(cfg.agents?.defaults?.model?.primary) ??
normalizeModelSelection(`${runtimeDefaultModel.provider}/${runtimeDefaultModel.model}`);
const resolvedThinkingDefaultRaw =
readStringParam(targetAgentConfig?.subagents ?? {}, "thinking") ??
@@ -191,6 +198,22 @@ export function createSessionsSpawnTool(opts?: {
}
thinkingOverride = normalized;
}
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnDepth: childDepth },
timeoutMs: 10_000,
});
} catch (err) {
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
status: "error",
error: messageText,
childSessionKey,
});
}
if (resolvedModel) {
try {
await callGateway({
@@ -240,6 +263,8 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey,
label: label || undefined,
task,
childDepth,
maxSpawnDepth,
});
const childIdem = crypto.randomUUID();
@@ -260,7 +285,7 @@ export function createSessionsSpawnTool(opts?: {
lane: AGENT_LANE_SUBAGENT,
extraSystemPrompt: childSystemPrompt,
thinking: thinkingOverride,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
timeout: runTimeoutSeconds,
label: label || undefined,
spawnedBy: spawnedByKey,
groupId: opts?.agentGroupId ?? undefined,
@@ -292,6 +317,7 @@ export function createSessionsSpawnTool(opts?: {
task,
cleanup,
label: label || undefined,
model: resolvedModel,
runTimeoutSeconds,
});

View File

@@ -0,0 +1,840 @@
import { Type } from "@sinclair/typebox";
import crypto from "node:crypto";
import type { SessionEntry } from "../../config/sessions.js";
import type { AnyAgentTool } from "./common.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
import { loadConfig } from "../../config/config.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 { 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 { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteer,
type SubagentRunRecord,
} from "../subagent-registry.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<string, number>();
const SubagentsToolSchema = Type.Object({
action: optionalStringEnum(SUBAGENT_ACTIONS),
target: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
recentMinutes: Type.Optional(Type.Number({ minimum: 1 })),
});
type SessionEntryResolution = {
storePath: string;
entry: SessionEntry | undefined;
};
type ResolvedRequesterKey = {
requesterSessionKey: string;
callerSessionKey: string;
callerIsSubagent: boolean;
};
type TargetResolution = {
entry?: SubagentRunRecord;
error?: string;
};
function formatDurationCompact(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const minutes = Math.max(1, Math.round(valueMs / 60_000));
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const minutesRemainder = minutes % 60;
if (hours < 24) {
return minutesRemainder > 0 ? `${hours}h${minutesRemainder}m` : `${hours}h`;
}
const days = Math.floor(hours / 24);
const hoursRemainder = hours % 24;
return hoursRemainder > 0 ? `${days}d${hoursRemainder}h` : `${days}d`;
}
function formatTokenShort(value?: number) {
if (!value || !Number.isFinite(value) || value <= 0) {
return undefined;
}
const n = Math.floor(value);
if (n < 1_000) {
return `${n}`;
}
if (n < 10_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
if (n < 1_000_000) {
return `${Math.round(n / 1_000)}k`;
}
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
}
function truncate(text: string, maxLength: number) {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength).trimEnd()}...`;
}
function resolveRunLabel(entry: SubagentRunRecord, fallback = "subagent") {
const raw = entry.label?.trim() || entry.task?.trim() || "";
return raw || fallback;
}
function resolveRunStatus(entry: SubagentRunRecord) {
if (!entry.endedAt) {
return "running";
}
const status = entry.outcome?.status ?? "done";
if (status === "ok") {
return "done";
}
if (status === "error") {
return "failed";
}
return status;
}
function sortRuns(runs: SubagentRunRecord[]) {
return [...runs].toSorted((a, b) => {
const aTime = a.startedAt ?? a.createdAt ?? 0;
const bTime = b.startedAt ?? b.createdAt ?? 0;
return bTime - aTime;
});
}
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 resolveTotalTokens(entry?: SessionEntry) {
if (!entry) {
return undefined;
}
if (typeof entry.totalTokens === "number" && Number.isFinite(entry.totalTokens)) {
return entry.totalTokens;
}
const input = typeof entry.inputTokens === "number" ? entry.inputTokens : 0;
const output = typeof entry.outputTokens === "number" ? entry.outputTokens : 0;
const total = input + output;
return total > 0 ? total : undefined;
}
function resolveIoTokens(entry?: SessionEntry) {
if (!entry) {
return undefined;
}
const input =
typeof entry.inputTokens === "number" && Number.isFinite(entry.inputTokens)
? entry.inputTokens
: 0;
const output =
typeof entry.outputTokens === "number" && Number.isFinite(entry.outputTokens)
? entry.outputTokens
: 0;
const total = input + output;
if (total <= 0) {
return undefined;
}
return { input, output, total };
}
function resolveUsageDisplay(entry?: SessionEntry) {
const io = resolveIoTokens(entry);
const promptCache = resolveTotalTokens(entry);
const parts: string[] = [];
if (io) {
const input = formatTokenShort(io.input) ?? "0";
const output = formatTokenShort(io.output) ?? "0";
parts.push(`tokens ${formatTokenShort(io.total)} (in ${input} / out ${output})`);
} else if (typeof promptCache === "number" && promptCache > 0) {
parts.push(`tokens ${formatTokenShort(promptCache)} prompt/cache`);
}
if (typeof promptCache === "number" && io && promptCache > io.total) {
parts.push(`prompt/cache ${formatTokenShort(promptCache)}`);
}
return parts.join(", ");
}
function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
options?: { recentMinutes?: number },
): TargetResolution {
const trimmed = token?.trim();
if (!trimmed) {
return { error: "Missing subagent target." };
}
const sorted = sortRuns(runs);
const recentMinutes = options?.recentMinutes ?? DEFAULT_RECENT_MINUTES;
const recentCutoff = Date.now() - recentMinutes * 60_000;
const numericOrder = [
...sorted.filter((entry) => !entry.endedAt),
...sorted.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff),
];
if (trimmed === "last") {
return { entry: sorted[0] };
}
if (/^\d+$/.test(trimmed)) {
const idx = Number.parseInt(trimmed, 10);
if (!Number.isFinite(idx) || idx <= 0 || idx > numericOrder.length) {
return { error: `Invalid subagent index: ${trimmed}` };
}
return { entry: numericOrder[idx - 1] };
}
if (trimmed.includes(":")) {
const bySessionKey = sorted.find((entry) => entry.childSessionKey === trimmed);
return bySessionKey
? { entry: bySessionKey }
: { error: `Unknown subagent session: ${trimmed}` };
}
const lowered = trimmed.toLowerCase();
const byExactLabel = sorted.filter((entry) => resolveRunLabel(entry).toLowerCase() === lowered);
if (byExactLabel.length === 1) {
return { entry: byExactLabel[0] };
}
if (byExactLabel.length > 1) {
return { error: `Ambiguous subagent label: ${trimmed}` };
}
const byLabelPrefix = sorted.filter((entry) =>
resolveRunLabel(entry).toLowerCase().startsWith(lowered),
);
if (byLabelPrefix.length === 1) {
return { entry: byLabelPrefix[0] };
}
if (byLabelPrefix.length > 1) {
return { error: `Ambiguous subagent label prefix: ${trimmed}` };
}
const byRunIdPrefix = sorted.filter((entry) => entry.runId.startsWith(trimmed));
if (byRunIdPrefix.length === 1) {
return { entry: byRunIdPrefix[0] };
}
if (byRunIdPrefix.length > 1) {
return { error: `Ambiguous subagent run id prefix: ${trimmed}` };
}
return { error: `Unknown subagent target: ${trimmed}` };
}
function resolveStorePathForKey(
cfg: ReturnType<typeof loadConfig>,
key: string,
parsed?: ParsedAgentSessionKey | null,
) {
return resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
}
function resolveSessionEntryForKey(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
cache: Map<string, Record<string, SessionEntry>>;
}): 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<typeof loadConfig>;
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,
};
}
// Check if this sub-agent can spawn children (orchestrator).
// If so, it should see its own children, not its parent's children.
const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg });
const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
if (callerDepth < maxSpawnDepth) {
// Orchestrator sub-agent: use its own session key as requester
// so it sees children it spawned.
return {
requesterSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
// Leaf sub-agent: walk up to its parent so it can see sibling runs.
const cache = new Map<string, Record<string, SessionEntry>>();
const callerEntry = resolveSessionEntryForKey({
cfg: params.cfg,
key: callerSessionKey,
cache,
}).entry;
const spawnedBy = typeof callerEntry?.spawnedBy === "string" ? callerEntry.spawnedBy.trim() : "";
return {
requesterSessionKey: spawnedBy || callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
async function killSubagentRun(params: {
cfg: ReturnType<typeof loadConfig>;
entry: SubagentRunRecord;
cache: Map<string, Record<string, SessionEntry>>;
}): 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<typeof loadConfig>;
parentChildSessionKey: string;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
}): Promise<{ killed: number; labels: string[] }> {
const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey);
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
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(resolveRunLabel(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",
name: "subagents",
description:
"List, kill, or steer spawned sub-agents for this requester session. Use this for sub-agent orchestration.",
parameters: SubagentsToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = (readStringParam(params, "action") ?? "list") as SubagentAction;
const cfg = loadConfig();
const requester = resolveRequesterKey({
cfg,
agentSessionKey: opts?.agentSessionKey,
});
const runs = sortRuns(listSubagentRunsForRequester(requester.requesterSessionKey));
const recentMinutesRaw = readNumberParam(params, "recentMinutes");
const recentMinutes = recentMinutesRaw
? Math.max(1, Math.min(MAX_RECENT_MINUTES, Math.floor(recentMinutesRaw)))
: DEFAULT_RECENT_MINUTES;
if (action === "list") {
const now = Date.now();
const recentCutoff = now - recentMinutes * 60_000;
const cache = new Map<string, Record<string, SessionEntry>>();
let index = 1;
const active = runs
.filter((entry) => !entry.endedAt)
.map((entry) => {
const sessionEntry = resolveSessionEntryForKey({
cfg,
key: entry.childSessionKey,
cache,
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = resolveUsageDisplay(sessionEntry);
const status = resolveRunStatus(entry);
const runtime = formatDurationCompact(now - (entry.startedAt ?? entry.createdAt));
const label = truncate(resolveRunLabel(entry), 48);
const task = truncate(entry.task.trim(), 72);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
const view = {
index,
runId: entry.runId,
sessionKey: entry.childSessionKey,
label,
task,
status,
runtime,
runtimeMs: now - (entry.startedAt ?? entry.createdAt),
model: resolveModelRef(sessionEntry) || entry.model,
totalTokens,
startedAt: entry.startedAt,
};
index += 1;
return { line, view };
});
const recent = runs
.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
.map((entry) => {
const sessionEntry = resolveSessionEntryForKey({
cfg,
key: entry.childSessionKey,
cache,
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = resolveUsageDisplay(sessionEntry);
const status = resolveRunStatus(entry);
const runtime = formatDurationCompact(
(entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
);
const label = truncate(resolveRunLabel(entry), 48);
const task = truncate(entry.task.trim(), 72);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
const view = {
index,
runId: entry.runId,
sessionKey: entry.childSessionKey,
label,
task,
status,
runtime,
runtimeMs: (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
model: resolveModelRef(sessionEntry) || entry.model,
totalTokens,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
};
index += 1;
return { line, view };
});
const text = buildListText({ active, recent, 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,
});
}
if (action === "kill") {
const target = readStringParam(params, "target", { required: true });
if (target === "all" || target === "*") {
const cache = new Map<string, Record<string, SessionEntry>>();
const seenChildSessionKeys = new Set<string>();
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(resolveRunLabel(entry));
}
}
// Traverse descendants even when the direct run is already finished.
const cascade = await cascadeKillChildren({
cfg,
parentChildSessionKey: childKey,
cache,
seenChildSessionKeys,
});
killed += cascade.killed;
killedLabels.push(...cascade.labels);
}
return jsonResult({
status: "ok",
action: "kill",
target: "all",
killed,
labels: killedLabels,
text:
killed > 0
? `killed ${killed} subagent${killed === 1 ? "" : "s"}.`
: "no running subagents to kill.",
});
}
const resolved = resolveSubagentTarget(runs, target, { recentMinutes });
if (!resolved.entry) {
return jsonResult({
status: "error",
action: "kill",
target,
error: resolved.error ?? "Unknown subagent target.",
});
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopResult = await killSubagentRun({
cfg,
entry: resolved.entry,
cache: killCache,
});
const seenChildSessionKeys = new Set<string>();
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: `${resolveRunLabel(resolved.entry)} is already finished.`,
});
}
const cascadeText =
cascade.killed > 0
? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})`
: "";
return jsonResult({
status: "ok",
action: "kill",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
label: resolveRunLabel(resolved.entry),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
text: stopResult.killed
? `killed ${resolveRunLabel(resolved.entry)}${cascadeText}.`
: `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveRunLabel(resolved.entry)}.`,
});
}
if (action === "steer") {
const target = readStringParam(params, "target", { required: true });
const message = readStringParam(params, "message", { required: true });
if (message.length > MAX_STEER_MESSAGE_CHARS) {
return jsonResult({
status: "error",
action: "steer",
target,
error: `Message too long (${message.length} chars, max ${MAX_STEER_MESSAGE_CHARS}).`,
});
}
const resolved = resolveSubagentTarget(runs, target, { recentMinutes });
if (!resolved.entry) {
return jsonResult({
status: "error",
action: "steer",
target,
error: resolved.error ?? "Unknown subagent target.",
});
}
if (resolved.entry.endedAt) {
return jsonResult({
status: "done",
action: "steer",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
text: `${resolveRunLabel(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({
cfg,
key: resolved.entry.childSessionKey,
cache: new Map<string, Record<string, SessionEntry>>(),
});
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",
action: "steer",
target,
runId,
sessionKey: resolved.entry.childSessionKey,
sessionId,
mode: "restart",
label: resolveRunLabel(resolved.entry),
text: `steered ${resolveRunLabel(resolved.entry)}.`,
});
}
return jsonResult({
status: "error",
error: "Unsupported action.",
});
},
};
}

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { resolveWorkspaceTemplateDir } from "./workspace-templates.js";
@@ -453,16 +453,16 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
return result;
}
const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]);
const MINIMAL_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]);
export function filterBootstrapFilesForSession(
files: WorkspaceBootstrapFile[],
sessionKey?: string,
): WorkspaceBootstrapFile[] {
if (!sessionKey || !isSubagentSessionKey(sessionKey)) {
if (!sessionKey || (!isSubagentSessionKey(sessionKey) && !isCronSessionKey(sessionKey))) {
return files;
}
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
return files.filter((file) => MINIMAL_BOOTSTRAP_ALLOWLIST.has(file.name));
}
export async function loadExtraBootstrapFiles(