diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index c4ed57ee843..a6009dd9e55 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -53,9 +53,12 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); -vi.mock("./subagent-followup.js", () => ({ +vi.mock("./subagent-followup-hints.js", () => ({ expectsSubagentFollowup: vi.fn().mockReturnValue(false), isLikelyInterimCronMessage: vi.fn().mockReturnValue(false), +})); + +vi.mock("./subagent-followup.runtime.js", () => ({ readDescendantSubagentFallbackReply: vi.fn().mockResolvedValue(undefined), waitForDescendantSubagentSummary: vi.fn().mockResolvedValue(undefined), })); @@ -73,12 +76,11 @@ import { } from "./delivery-dispatch.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import type { RunCronAgentTurnResult } from "./run.js"; +import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; import { - expectsSubagentFollowup, - isLikelyInterimCronMessage, readDescendantSubagentFallbackReply, waitForDescendantSubagentSummary, -} from "./subagent-followup.js"; +} from "./subagent-followup.runtime.js"; // --------------------------------------------------------------------------- // Helpers diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 7a223438e5d..96922e7000a 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -20,12 +20,7 @@ import type { CronJob, CronRunTelemetry } from "../types.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import { pickSummaryFromOutput } from "./helpers.js"; import type { RunCronAgentTurnResult } from "./run.js"; -import { - expectsSubagentFollowup, - isLikelyInterimCronMessage, - readDescendantSubagentFallbackReply, - waitForDescendantSubagentSummary, -} from "./subagent-followup.js"; +import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; function normalizeDeliveryTarget(channel: string, to: string): string { const channelLower = channel.trim().toLowerCase(); @@ -141,6 +136,9 @@ type CompletedDirectCronDelivery = { }; let gatewayCallRuntimePromise: Promise | undefined; +let subagentFollowupRuntimePromise: + | Promise + | undefined; const COMPLETED_DIRECT_CRON_DELIVERIES = new Map(); @@ -149,6 +147,13 @@ async function loadGatewayCallRuntime(): Promise { + subagentFollowupRuntimePromise ??= import("./subagent-followup.runtime.js"); + return await subagentFollowupRuntimePromise; +} + function cloneDeliveryResults( results: readonly OutboundDeliveryResult[], ): OutboundDeliveryResult[] { @@ -545,21 +550,27 @@ export async function dispatchCronDelivery( const initialSynthesizedText = synthesizedText.trim(); let activeSubagentRuns = countActiveDescendantRuns(params.agentSessionKey); const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText); + const shouldCheckCompletedDescendants = + activeSubagentRuns === 0 && isLikelyInterimCronMessage(initialSynthesizedText); + const needsSubagentFollowupRuntime = + shouldCheckCompletedDescendants || activeSubagentRuns > 0 || expectedSubagentFollowup; + const subagentFollowupRuntime = needsSubagentFollowupRuntime + ? await loadSubagentFollowupRuntime() + : undefined; // Also check for already-completed descendants. If the subagent finished // before delivery-dispatch runs, activeSubagentRuns is 0 and // expectedSubagentFollowup may be false (e.g. cron said "on it" which // doesn't match the narrow hint list). We still need to use the // descendant's output instead of the interim cron text. - const completedDescendantReply = - activeSubagentRuns === 0 && isLikelyInterimCronMessage(initialSynthesizedText) - ? await readDescendantSubagentFallbackReply({ - sessionKey: params.agentSessionKey, - runStartedAt: params.runStartedAt, - }) - : undefined; + const completedDescendantReply = shouldCheckCompletedDescendants + ? await subagentFollowupRuntime?.readDescendantSubagentFallbackReply({ + sessionKey: params.agentSessionKey, + runStartedAt: params.runStartedAt, + }) + : undefined; const hadDescendants = activeSubagentRuns > 0 || Boolean(completedDescendantReply); if (activeSubagentRuns > 0 || expectedSubagentFollowup) { - let finalReply = await waitForDescendantSubagentSummary({ + let finalReply = await subagentFollowupRuntime?.waitForDescendantSubagentSummary({ sessionKey: params.agentSessionKey, initialReply: initialSynthesizedText, timeoutMs: params.timeoutMs, @@ -567,7 +578,7 @@ export async function dispatchCronDelivery( }); activeSubagentRuns = countActiveDescendantRuns(params.agentSessionKey); if (!finalReply && activeSubagentRuns === 0) { - finalReply = await readDescendantSubagentFallbackReply({ + finalReply = await subagentFollowupRuntime?.readDescendantSubagentFallbackReply({ sessionKey: params.agentSessionKey, runStartedAt: params.runStartedAt, }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index bcfb9d492ea..66bd221755e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -61,7 +61,7 @@ import { buildCronAgentDefaultsConfig } from "./run-config.js"; import { resolveCronAgentSessionKey } from "./session-key.js"; import { resolveCronSession } from "./session.js"; import { resolveCronSkillsSnapshot } from "./skills-snapshot.js"; -import { isLikelyInterimCronMessage } from "./subagent-followup.js"; +import { isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; let sessionStoreRuntimePromise: | Promise diff --git a/src/cron/isolated-agent/subagent-followup-hints.ts b/src/cron/isolated-agent/subagent-followup-hints.ts new file mode 100644 index 00000000000..d86e99d90e9 --- /dev/null +++ b/src/cron/isolated-agent/subagent-followup-hints.ts @@ -0,0 +1,46 @@ +const SUBAGENT_FOLLOWUP_HINTS = [ + "subagent spawned", + "spawned a subagent", + "auto-announce when done", + "both subagents are running", + "wait for them to report back", +] as const; + +const INTERIM_CRON_HINTS = [ + "on it", + "pulling everything together", + "give me a few", + "give me a few min", + "few minutes", + "let me compile", + "i'll gather", + "i will gather", + "working on it", + "retrying now", + "should be about", + "should have your summary", + "it'll auto-announce when done", + "it will auto-announce when done", + ...SUBAGENT_FOLLOWUP_HINTS, +] as const; + +function normalizeHintText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function isLikelyInterimCronMessage(value: string): boolean { + const normalized = normalizeHintText(value); + if (!normalized) { + // Empty text after payload filtering means the agent either returned + // NO_REPLY (deliberately silent) or produced no deliverable content. + // Do not treat this as an interim acknowledgement that needs a rerun. + return false; + } + const words = normalized.split(" ").filter(Boolean).length; + return words <= 45 && INTERIM_CRON_HINTS.some((hint) => normalized.includes(hint)); +} + +export function expectsSubagentFollowup(value: string): boolean { + const normalized = normalizeHintText(value); + return Boolean(normalized && SUBAGENT_FOLLOWUP_HINTS.some((hint) => normalized.includes(hint))); +} diff --git a/src/cron/isolated-agent/subagent-followup.runtime.ts b/src/cron/isolated-agent/subagent-followup.runtime.ts new file mode 100644 index 00000000000..d13df960cf5 --- /dev/null +++ b/src/cron/isolated-agent/subagent-followup.runtime.ts @@ -0,0 +1,4 @@ +export { + readDescendantSubagentFallbackReply, + waitForDescendantSubagentSummary, +} from "./subagent-followup.js"; diff --git a/src/cron/isolated-agent/subagent-followup.test.ts b/src/cron/isolated-agent/subagent-followup.test.ts index 809f811227f..d87abe5dd0e 100644 --- a/src/cron/isolated-agent/subagent-followup.test.ts +++ b/src/cron/isolated-agent/subagent-followup.test.ts @@ -5,9 +5,8 @@ vi.hoisted(() => { process.env.OPENCLAW_TEST_FAST = "1"; }); +import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; import { - expectsSubagentFollowup, - isLikelyInterimCronMessage, readDescendantSubagentFallbackReply, waitForDescendantSubagentSummary, } from "./subagent-followup.js"; diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index 6ac152928a4..f11975d6788 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -2,6 +2,8 @@ import { listDescendantRunsForRequester } from "../../agents/subagent-registry-r import { readLatestAssistantReply } from "../../agents/tools/agent-step.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { callGateway } from "../../gateway/call.js"; +import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; +export { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; function resolveCronSubagentTimings() { const fastTestMode = process.env.OPENCLAW_TEST_FAST === "1"; @@ -12,53 +14,6 @@ function resolveCronSubagentTimings() { }; } -const SUBAGENT_FOLLOWUP_HINTS = [ - "subagent spawned", - "spawned a subagent", - "auto-announce when done", - "both subagents are running", - "wait for them to report back", -] as const; - -const INTERIM_CRON_HINTS = [ - "on it", - "pulling everything together", - "give me a few", - "give me a few min", - "few minutes", - "let me compile", - "i'll gather", - "i will gather", - "working on it", - "retrying now", - "should be about", - "should have your summary", - "it'll auto-announce when done", - "it will auto-announce when done", - ...SUBAGENT_FOLLOWUP_HINTS, -] as const; - -function normalizeHintText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - -export function isLikelyInterimCronMessage(value: string): boolean { - const normalized = normalizeHintText(value); - if (!normalized) { - // Empty text after payload filtering means the agent either returned - // NO_REPLY (deliberately silent) or produced no deliverable content. - // Do not treat this as an interim acknowledgement that needs a rerun. - return false; - } - const words = normalized.split(" ").filter(Boolean).length; - return words <= 45 && INTERIM_CRON_HINTS.some((hint) => normalized.includes(hint)); -} - -export function expectsSubagentFollowup(value: string): boolean { - const normalized = normalizeHintText(value); - return Boolean(normalized && SUBAGENT_FOLLOWUP_HINTS.some((hint) => normalized.includes(hint))); -} - export async function readDescendantSubagentFallbackReply(params: { sessionKey: string; runStartedAt: number;