mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:51:23 +00:00
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:
56
src/agents/bash-tools.process.poll-timeout.test.ts
Normal file
56
src/agents/bash-tools.process.poll-timeout.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DEFAULT_TOOL_ALLOW = [
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
87
src/agents/subagent-depth.test.ts
Normal file
87
src/agents/subagent-depth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
src/agents/subagent-depth.ts
Normal file
176
src/agents/subagent-depth.ts
Normal 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;
|
||||
}
|
||||
165
src/agents/subagent-registry.nested.test.ts
Normal file
165
src/agents/subagent-registry.nested.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
196
src/agents/subagent-registry.steer-restart.test.ts
Normal file
196
src/agents/subagent-registry.steer-restart.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -10,7 +10,6 @@ describe("tool display details", () => {
|
||||
task: "double-message-bug-gpt",
|
||||
label: 0,
|
||||
runTimeoutSeconds: 0,
|
||||
timeoutSeconds: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
49
src/agents/tools/agent-step.test.ts
Normal file
49
src/agents/tools/agent-step.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
840
src/agents/tools/subagents-tool.ts
Normal file
840
src/agents/tools/subagents-tool.ts
Normal 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.",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user