diff --git a/docs/concepts/subagent-yield-handoff.md b/docs/concepts/subagent-yield-handoff.md index e7ca58a28065..8524cec19bb4 100644 --- a/docs/concepts/subagent-yield-handoff.md +++ b/docs/concepts/subagent-yield-handoff.md @@ -35,6 +35,11 @@ The implementation owners are `subagent-registry-requester-yield.ts`, `agent-task-tracking.ts`. `adoptPausedSubagentRunForFollowUp` uses the existing registry replacement operation; it does not create a second delegated task. +An explicit `waitFor: "message"` counts as continuation evidence after the +registry accepts the wait. The attempt carries that fact into terminal reply +presentation, so a registered message wait does not produce a missing-continuation +warning. Refused or unregistered waits do not provide that evidence. + Private child results wait for their spawning turn to settle before individual announcement admission. Normal settlement resumes each finished private child, even while siblings are still running. Explicit yield assigns the frozen batch diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-types.ts b/src/agents/embedded-agent-runner/run/attempt-execution-types.ts index be4df418c5c6..5271a7db7cb6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-types.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-types.ts @@ -75,6 +75,7 @@ export type EmbeddedAttemptExecutionPhaseInput = { yieldDetected: boolean; yieldMessage: string | null; yieldAcknowledgment?: string; + yieldMessageWaitRegistered?: boolean; }; setToolSearchCatalogExecutor: ( executor: ReturnType["toolSearchCatalogExecutor"], diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts index 8519883491f6..5a7602726993 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts @@ -5,18 +5,19 @@ const hoisted = vi.hoisted(() => ({ isMidTurnPrecheckSignal: vi.fn(() => false), isSessionsYieldAbortError: vi.fn(() => false), markYieldAborted: vi.fn(), - persistSessionsYieldContextMessage: vi.fn(async () => undefined), + sendCustomMessage: vi.fn(async () => undefined), releaseLeasedSteering: vi.fn(), stripSessionsYieldArtifacts: vi.fn(), - waitForSessionsYieldAbortSettle: vi.fn(async () => undefined), + waitForEmbeddedAbortSettle: vi.fn(async () => undefined), withOwnedTranscriptWrite: vi.fn(async (operation: () => unknown) => await operation()), })); vi.mock("./attempt-sessions-yield.js", () => ({ isSessionsYieldAbortError: hoisted.isSessionsYieldAbortError, - persistSessionsYieldContextMessage: hoisted.persistSessionsYieldContextMessage, stripSessionsYieldArtifacts: hoisted.stripSessionsYieldArtifacts, - waitForSessionsYieldAbortSettle: hoisted.waitForSessionsYieldAbortSettle, +})); +vi.mock("./attempt-subscription-cleanup.js", () => ({ + waitForEmbeddedAbortSettle: hoisted.waitForEmbeddedAbortSettle, })); vi.mock("./midturn-precheck.js", () => ({ isMidTurnPrecheckSignal: hoisted.isMidTurnPrecheckSignal, @@ -33,6 +34,7 @@ function createInput(overrides: Partial = {}): PromptErrorInpu agent: { state: { messages: [] } }, messages: [], sessionManager: SessionManager.inMemory(), + sendCustomMessage: hoisted.sendCustomMessage, }, attempt: { runId: "run-1", sessionId: "session-1" }, error: new Error("prompt failed"), @@ -96,15 +98,21 @@ describe("handleEmbeddedAttemptPromptError", () => { await expect(handleEmbeddedAttemptPromptError(input)).resolves.toEqual({}); expect(hoisted.markYieldAborted).toHaveBeenCalledOnce(); - expect(hoisted.waitForSessionsYieldAbortSettle).toHaveBeenCalledWith({ - settlePromise, + expect(hoisted.waitForEmbeddedAbortSettle).toHaveBeenCalledWith({ + promise: settlePromise, runId: "run-1", sessionId: "session-1", + reason: "sessions_yield", }); expect(hoisted.stripSessionsYieldArtifacts).toHaveBeenCalledWith(input.activeSession); - expect(hoisted.persistSessionsYieldContextMessage).toHaveBeenCalledWith( - input.activeSession, - "wait for follow-up", + expect(hoisted.sendCustomMessage).toHaveBeenCalledWith( + { + customType: "openclaw.sessions_yield", + content: expect.stringContaining("wait for follow-up"), + display: false, + details: { source: "sessions_yield", message: "wait for follow-up" }, + }, + { triggerTurn: false }, ); }); @@ -112,7 +120,7 @@ describe("handleEmbeddedAttemptPromptError", () => { const recoveryError = new Error("settle failed"); let marked = false; hoisted.isSessionsYieldAbortError.mockReturnValue(true); - hoisted.waitForSessionsYieldAbortSettle.mockImplementationOnce(async () => { + hoisted.waitForEmbeddedAbortSettle.mockImplementationOnce(async () => { expect(marked).toBe(true); throw recoveryError; }); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts index 16e0b33f380d..18e0aafc1ca3 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-submit.ts @@ -2,6 +2,7 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ImageContent } from "../../../llm/types.js"; import type { createTrajectoryRuntimeRecorder } from "../../../trajectory/runtime.js"; import type { AgentMessage } from "../../runtime/index.js"; +import { buildSessionsYieldContextMessage } from "../../sessions-yield-context.js"; import { agentSessionQueuePromptContext } from "../../sessions/agent-session-prompting.js"; import { attachPromptCompactionRequestBudget, @@ -26,10 +27,9 @@ import { } from "./attempt-llm-boundary.js"; import { isSessionsYieldAbortError, - persistSessionsYieldContextMessage, stripSessionsYieldArtifacts, - waitForSessionsYieldAbortSettle, } from "./attempt-sessions-yield.js"; +import { waitForEmbeddedAbortSettle } from "./attempt-subscription-cleanup.js"; import { wrapStreamFnWithMessageTransform } from "./message-transform-stream-wrapper.js"; import { isMidTurnPrecheckSignal, type MidTurnPrecheckRequest } from "./midturn-precheck.js"; import type { RuntimeContextCustomMessage } from "./runtime-context-prompt.js"; @@ -287,10 +287,11 @@ export async function handleEmbeddedAttemptPromptError(input: { if (yieldAborted) { // Publish terminal state before fallible recovery so outer cleanup still recognizes the yield. input.markYieldAborted(); - await waitForSessionsYieldAbortSettle({ - settlePromise: input.yieldAbortSettled, + await waitForEmbeddedAbortSettle({ + promise: input.yieldAbortSettled, runId: input.attempt.runId, sessionId: input.attempt.sessionId, + reason: "sessions_yield", }); await input.withOwnedTranscriptWrite(async () => { const transcriptRewritten = await withSessionManagerWrite( @@ -298,7 +299,12 @@ export async function handleEmbeddedAttemptPromptError(input: { () => stripSessionsYieldArtifacts(input.activeSession), ); if (input.yieldMessage) { - await persistSessionsYieldContextMessage(input.activeSession, input.yieldMessage); + await input.activeSession.sendCustomMessage( + buildSessionsYieldContextMessage(input.yieldMessage), + { + triggerTurn: false, + }, + ); } const target = transcriptRewritten && input.activeSession.sessionManager.getSessionTarget(); if (target) { diff --git a/src/agents/embedded-agent-runner/run/attempt-result.test.ts b/src/agents/embedded-agent-runner/run/attempt-result.test.ts index 5b6c56e31068..a8669af6b6d9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.test.ts @@ -35,6 +35,7 @@ function createResultFixture(params?: { didSendViaMessagingTool?: boolean; yieldDetected?: boolean; yieldAcknowledgment?: string; + yieldMessageWaitRegistered?: boolean; assistantTexts?: readonly string[]; toolMetas?: Array<{ toolName: string; @@ -130,6 +131,7 @@ function createResultFixture(params?: { readYieldState: () => ({ yieldDetected: params?.yieldDetected ?? false, yieldAcknowledgment: params?.yieldAcknowledgment, + yieldMessageWaitRegistered: params?.yieldMessageWaitRegistered, }), }, prepared: { @@ -196,6 +198,7 @@ describe("attempt result projection", () => { fixture.input.lifecycle.readYieldState = () => ({ yieldDetected: true, yieldAcknowledgment: "later yield", + yieldMessageWaitRegistered: true, }); }); @@ -216,6 +219,7 @@ describe("attempt result projection", () => { expect(result.attemptUsage).toBeUndefined(); expect(result).toHaveProperty("yieldDetected", undefined); expect(result).toHaveProperty("yieldAcknowledgment", undefined); + expect(result).toHaveProperty("yieldMessageWaitRegistered", undefined); expect(result).not.toHaveProperty("beforeAgentFinalizeRevisionReason"); }); @@ -518,17 +522,22 @@ describe("attempt result projection", () => { }, ); - it("carries the explicit yield acknowledgment separately from continuation context", () => { - expect( - completeResult({ + it.each([true, false, undefined])( + "carries yield acknowledgment and owner-recorded message wait (%s) separately from private context", + (yieldMessageWaitRegistered) => { + expect( + completeResult({ + yieldDetected: true, + yieldAcknowledgment: "Waiting for a continuation.", + yieldMessageWaitRegistered, + }), + ).toMatchObject({ yieldDetected: true, - yieldAcknowledgment: "Research started; results will follow.", - }), - ).toMatchObject({ - yieldDetected: true, - yieldAcknowledgment: "Research started; results will follow.", - }); - }); + yieldAcknowledgment: "Waiting for a continuation.", + yieldMessageWaitRegistered, + }); + }, + ); it("defaults missing replay metadata to replay-unsafe", () => { const attempt = completeResult(); diff --git a/src/agents/embedded-agent-runner/run/attempt-result.ts b/src/agents/embedded-agent-runner/run/attempt-result.ts index 3e8485c9fe34..92e5f38ece08 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.ts @@ -208,6 +208,7 @@ export function completeEmbeddedAttemptResult( contextBudgetStatus: prompt.contextBudgetStatus, yieldDetected: input.lifecycle.readYieldState().yieldDetected, yieldAcknowledgment: input.lifecycle.readYieldState().yieldAcknowledgment, + yieldMessageWaitRegistered: input.lifecycle.readYieldState().yieldMessageWaitRegistered, didDeliverSourceReplyViaMessageTool: hasDeliveredSourceReply(), }; const terminal = projectAgentRunAttemptTerminal(state.terminal); diff --git a/src/agents/embedded-agent-runner/run/attempt-sessions-yield.ts b/src/agents/embedded-agent-runner/run/attempt-sessions-yield.ts index 5f649098da5a..65dcb9576fde 100644 --- a/src/agents/embedded-agent-runner/run/attempt-sessions-yield.ts +++ b/src/agents/embedded-agent-runner/run/attempt-sessions-yield.ts @@ -1,27 +1,12 @@ import type { AssistantMessage, AssistantMessageEventStreamLike } from "../../../llm/types.js"; import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; import type { AgentMessage } from "../../runtime/index.js"; -import { buildSessionsYieldContextMessage } from "../../sessions-yield-context.js"; import type { SessionManager } from "../../sessions/index.js"; import { buildUsageWithNoCost } from "../../stream-message-shared.js"; import { isRunnerAbortError } from "../abort.js"; -import { waitForEmbeddedAbortSettle } from "./attempt-subscription-cleanup.js"; const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt"; -export async function waitForSessionsYieldAbortSettle(params: { - settlePromise: Promise | null; - runId: string; - sessionId: string; -}): Promise { - await waitForEmbeddedAbortSettle({ - promise: params.settlePromise, - runId: params.runId, - sessionId: params.sessionId, - reason: "sessions_yield", - }); -} - // Return a synthetic aborted response so agent runtime unwinds without a real provider call. export function createYieldAbortedResponse(model: { api?: string; @@ -77,25 +62,6 @@ export function queueSessionsYieldInterruptMessage(activeSession: { }); } -export async function persistSessionsYieldContextMessage( - activeSession: { - sendCustomMessage: ( - message: { - customType: string; - content: string; - display: boolean; - details?: Record; - }, - options?: { triggerTurn?: boolean }, - ) => Promise; - }, - message: string, -) { - await activeSession.sendCustomMessage(buildSessionsYieldContextMessage(message), { - triggerTurn: false, - }); -} - // Remove the synthetic yield interrupt + aborted assistant entry from the live transcript. // After strip, the transcript must end with a non-assistant role so subagent // completion auto-announce can inject a continuation turn. diff --git a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts index c628bc8c1498..1c47aeae859e 100644 --- a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts @@ -18,13 +18,14 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("waitForSessionsYieldAbortSettle", () => { +describe("waitForEmbeddedAbortSettle for sessions_yield", () => { it("logs rejected settlement and clears its pending timer", async () => { - const { waitForSessionsYieldAbortSettle } = await import("./attempt-sessions-yield.js"); - await waitForSessionsYieldAbortSettle({ - settlePromise: Promise.reject(new Error("settle failed")), + const { waitForEmbeddedAbortSettle } = await import("./attempt-subscription-cleanup.js"); + await waitForEmbeddedAbortSettle({ + promise: Promise.reject(new Error("settle failed")), runId: "run-1", sessionId: "session-1", + reason: "sessions_yield", }); expect(mocks.warn).toHaveBeenCalledExactlyOnceWith( @@ -34,11 +35,12 @@ describe("waitForSessionsYieldAbortSettle", () => { }); it("skips missing settlement without scheduling a timer", async () => { - const { waitForSessionsYieldAbortSettle } = await import("./attempt-sessions-yield.js"); - await waitForSessionsYieldAbortSettle({ - settlePromise: null, + const { waitForEmbeddedAbortSettle } = await import("./attempt-subscription-cleanup.js"); + await waitForEmbeddedAbortSettle({ + promise: null, runId: "run-1", sessionId: "session-1", + reason: "sessions_yield", }); expect(vi.getTimerCount()).toBe(0); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 305f3817435f..251fa268b47c 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -245,10 +245,11 @@ async function runEmbeddedAttemptOwned( attempt: params, setup, markCoreToolStage: (name) => corePluginToolStages.mark(name), - onYield: (message, acknowledgment) => { + onYield: (message, acknowledgment, messageWaitRegistered) => { yieldDetected = true; yieldMessage = message; yieldAcknowledgment = acknowledgment; + yieldMessageWaitRegistered = messageWaitRegistered; queueYieldInterruptForSession?.(); runAbortController.abort(SESSIONS_YIELD_ABORT_REASON); abortSessionForYield?.(); @@ -307,6 +308,7 @@ async function runEmbeddedAttemptOwned( let yieldDetected = false; let yieldMessage: string | null = null; let yieldAcknowledgment: string | undefined; + let yieldMessageWaitRegistered: boolean | undefined; // Late-binding reference so onYield can abort the session (declared after tool creation) let abortSessionForYield: (() => void) | null = null; let queueYieldInterruptForSession: (() => void) | null = null; @@ -465,6 +467,7 @@ async function runEmbeddedAttemptOwned( yieldDetected, yieldMessage, yieldAcknowledgment, + yieldMessageWaitRegistered, }), setToolSearchCatalogExecutor: (executor) => { toolSearchCatalogExecutor = executor; diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.ts index 2eb009b9fa95..c8b98b0a8ace 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-resolution.ts @@ -226,6 +226,7 @@ interface YieldContinuationAttempt { successfulCronAdds?: number; acceptedSessionSpawns?: readonly { runId: string; childSessionKey: string }[]; runtimeContinuationStarted?: boolean; + yieldMessageWaitRegistered?: boolean; messagingToolSentTexts?: readonly string[]; messagingToolSentMediaUrls?: readonly string[]; messagingToolSentTargets?: readonly MessagingToolSend[]; @@ -242,6 +243,7 @@ export function hasYieldContinuationEvidence(attempt: YieldContinuationAttempt): hasCommittedMessagingToolDeliveryEvidence(attempt) || hasAcceptedSessionSpawn(attempt.acceptedSessionSpawns) || attempt.runtimeContinuationStarted === true || + attempt.yieldMessageWaitRegistered === true || hasAsyncActivity(attempt.toolMetas) || (attempt.successfulCronAdds ?? 0) > 0 ); diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index f442dedc9e64..7a25ad516e9f 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -434,6 +434,8 @@ export type EmbeddedRunAttemptResult = { yieldDetected?: boolean; /** Explicit user-facing waiting status supplied to sessions_yield. */ yieldAcknowledgment?: string; + /** The registry accepted this attempt's explicit incoming-message wait. */ + yieldMessageWaitRegistered?: boolean; /** * True when code mode owned this attempt's model tool surface. Absent means * the harness did not report engagement (treated as not engaged), which is diff --git a/src/agents/embedded-agent-runner/sessions-yield.orchestration.test-support.ts b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test-support.ts index 4c25de9bd7a4..441843bbd96d 100644 --- a/src/agents/embedded-agent-runner/sessions-yield.orchestration.test-support.ts +++ b/src/agents/embedded-agent-runner/sessions-yield.orchestration.test-support.ts @@ -406,6 +406,83 @@ describe("sessions_yield orchestration", () => { }); describe("yield with continuation evidence", () => { + it.each(["accepted", "refused", "unregistered"] as const)( + "uses the owner's message wait registration as continuation evidence (%s)", + async (registration) => { + const registry = await import("../subagents/registry/subagent-registry.test-helpers.js"); + const { createRequesterYieldCallback } = + await import("../openclaw-tools.requester-yield.js"); + const { createSessionsYieldTool } = await import("../tools/sessions-yield-tool.js"); + const params = { + ...createOverflowRunParams(state), + sessionKey: "agent:main:subagent:message-wait", + runId: "message-wait-run", + }; + registry.resetSubagentRegistryForTests({ persist: false }); + if (registration !== "unregistered") { + registry.addSubagentRunForTests( + createSubagentRunRecord({ + runId: params.runId, + childSessionKey: params.sessionKey, + requesterSessionKey: "agent:main:parent", + expectsCompletionMessage: true, + execution: { status: "running" }, + completion: { required: true }, + delivery: { status: "pending" }, + suppressCompletionDelivery: registration === "refused", + }), + ); + } + mockedRunEmbeddedAttempt.mockImplementationOnce(async () => { + let yieldMessageWaitRegistered: boolean | undefined; + const onYield = vi.fn( + (_message: string, _acknowledgment?: string, registered?: boolean) => { + yieldMessageWaitRegistered = registered; + }, + ); + const tool = createSessionsYieldTool({ + sessionId: params.sessionId, + claimYield: createRequesterYieldCallback({ + requesterSessionKey: params.sessionKey, + requesterAgentId: params.agentId, + requesterTurnRunId: params.runId, + }), + onYield, + }); + expect((await tool.execute("yield-message", { waitFor: "message" })).details).toEqual({ + status: "yielded", + }); + expect( + registry.getSubagentRunByRunId(params.runId)?.requesterSettleWake?.pauseNotice, + ).toEqual( + registration === "accepted" + ? { acknowledgment: "Paused awaiting continuation." } + : undefined, + ); + return makeAttemptResult({ + yieldDetected: onYield.mock.calls.length > 0, + yieldMessageWaitRegistered, + assistantTexts: [], + }); + }); + try { + const result = await runEmbeddedAgent(params); + expect(result.meta.yielded).toBe(true); + expect(result.payloads ?? []).toEqual( + registration === "accepted" + ? [] + : [ + { + text: "⚠️ Turn yielded without a continuation source. Send a message to resume.", + }, + ], + ); + } finally { + registry.resetSubagentRegistryForTests({ persist: false }); + } + }, + ); + it("rejects an unregistered accepted child instead of silently yielding", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.progress.test-support.ts b/src/agents/embedded-agent-subscribe.handlers.tools.progress.test-support.ts index a4bc3cc388f5..027a99edfbe1 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.progress.test-support.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.progress.test-support.ts @@ -350,7 +350,7 @@ export function registerToolChannelProgressTests({ updateTool(ctx, { toolName: tool.name, toolCallId, partialResult: result }); await endTool(ctx, { toolName: tool.name, toolCallId, result, isError: false }); - expect(onYield).toHaveBeenCalledWith(args.message, args.acknowledgment); + expect(onYield).toHaveBeenCalledWith(args.message, args.acknowledgment, undefined); expect(events).toHaveLength(6); expect( events diff --git a/src/agents/openclaw-tools.requester-yield.ts b/src/agents/openclaw-tools.requester-yield.ts index e1c25991833f..a298c5468df0 100644 --- a/src/agents/openclaw-tools.requester-yield.ts +++ b/src/agents/openclaw-tools.requester-yield.ts @@ -59,11 +59,13 @@ export function createRequesterYieldCallback(params: { if (canWaitForMessage && intent?.waitFor === "message" && params.requesterTurnRunId) { const { markSubagentMessageWait } = await import("./subagents/registry/subagent-registry.js"); - await markSubagentMessageWait({ - runId: params.requesterTurnRunId, - sessionKey: requesterSessionKey!, - acknowledgment: intent.acknowledgment, - }); + return { + messageWaitRegistered: await markSubagentMessageWait({ + runId: params.requesterTurnRunId, + sessionKey: requesterSessionKey!, + acknowledgment: intent.acknowledgment, + }), + }; } return true; }; diff --git a/src/agents/openclaw-tools.types.ts b/src/agents/openclaw-tools.types.ts index 127440e73a49..b40a0e080bba 100644 --- a/src/agents/openclaw-tools.types.ts +++ b/src/agents/openclaw-tools.types.ts @@ -16,6 +16,7 @@ import type { SpawnedToolContext } from "./spawned-context.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; import type { CronToolOptions } from "./tools/cron-tool.types.js"; import type { QuestionPromptDelivery } from "./tools/question-prompt-send.js"; +import type { SessionsYieldCallback } from "./tools/sessions-yield-tool.js"; /** Options shared by the coding-tool factory and its OpenClaw tool surface. */ export type OpenClawSharedToolsOptions = { @@ -92,7 +93,7 @@ export type OpenClawSharedToolsOptions = { enableHeartbeatTool?: boolean; /** Host-only observation after a canonical progress-card replacement commits. */ onProgressCardPlanSaved?: (unfinished: boolean) => void; - onYield?: (message: string, acknowledgment?: string) => Promise | void; + onYield?: SessionsYieldCallback; claimYieldCompletion?: () => boolean | Promise; /** Records hot-path tool-prep stages for reply startup diagnostics. */ recordToolPrepStage?: (name: string) => void; diff --git a/src/agents/subagents/registry/subagent-registry-public-api.ts b/src/agents/subagents/registry/subagent-registry-public-api.ts index ff943d1af2e3..9010b7317592 100644 --- a/src/agents/subagents/registry/subagent-registry-public-api.ts +++ b/src/agents/subagents/registry/subagent-registry-public-api.ts @@ -285,7 +285,7 @@ export function createSubagentRegistryPublicApi(config: { }; assertCurrent(); await restoreOnce(stateContext); - await markSubagentMessageWaitInRuns({ + return await markSubagentMessageWaitInRuns({ ...params, runs, context: stateContext, diff --git a/src/agents/subagents/registry/subagent-registry-run-pause.ts b/src/agents/subagents/registry/subagent-registry-run-pause.ts index 0142c0b9bd34..2bfa63c990ee 100644 --- a/src/agents/subagents/registry/subagent-registry-run-pause.ts +++ b/src/agents/subagents/registry/subagent-registry-run-pause.ts @@ -23,7 +23,7 @@ export async function markSubagentMessageWaitInRuns(params: { context: OpenClawStateWorkerContext; assertCurrent: () => void; persist: Parameters[0]["persist"]; -}): Promise { +}): Promise { params.assertCurrent(); const entry = params.runs.get(params.runId); if ( @@ -34,10 +34,12 @@ export async function markSubagentMessageWaitInRuns(params: { entry.execution.status !== "running" || entry.killIntent || entry.killReconciliation || - entry.suppressCompletionDelivery || - entry.requesterSettleWake?.pauseNotice + entry.suppressCompletionDelivery ) { - return; + return false; + } + if (entry.requesterSettleWake?.pauseNotice) { + return true; } const previous = captureSubagentRunMutationSnapshot(entry); entry.requesterSettleWake = { @@ -66,6 +68,7 @@ export async function markSubagentMessageWaitInRuns(params: { } catch (error) { throw new SubagentRegistryWriteError("committed", error, result.publication); } + return true; } export function markSubagentRunPausedAfterYield(params: { diff --git a/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 9c9bc404ba93..cdf79b0e2933 100644 --- a/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -392,7 +392,11 @@ describe("subagent registry lifecycle error grace", () => { status: "yielded", }); expect(onYield).toHaveBeenCalledOnce(); - expect(onYield).toHaveBeenCalledWith("Wait for the visible dashboard child", undefined); + expect(onYield).toHaveBeenCalledWith( + "Wait for the visible dashboard child", + undefined, + undefined, + ); await settleYieldedCliTurn({ requesterTurnRunId, diff --git a/src/agents/tools/sessions-yield-tool.test.ts b/src/agents/tools/sessions-yield-tool.test.ts index 31daf241043c..1e97a4bdbc2d 100644 --- a/src/agents/tools/sessions-yield-tool.test.ts +++ b/src/agents/tools/sessions-yield-tool.test.ts @@ -70,7 +70,7 @@ describe("sessions_yield tool", () => { expect(details.status).toBe("yielded"); expect(details).not.toHaveProperty("message"); expect(onYield).toHaveBeenCalledOnce(); - expect(onYield).toHaveBeenCalledWith("Turn yielded.", undefined); + expect(onYield).toHaveBeenCalledWith("Turn yielded.", undefined, undefined); }); it.each([undefined, "Research started; results will follow."])( @@ -91,7 +91,7 @@ describe("sessions_yield tool", () => { }); expect(JSON.stringify(result)).not.toContain(message); expect(onYield).toHaveBeenCalledOnce(); - expect(onYield).toHaveBeenCalledWith(message, acknowledgment); + expect(onYield).toHaveBeenCalledWith(message, acknowledgment, undefined); }, ); diff --git a/src/agents/tools/sessions-yield-tool.ts b/src/agents/tools/sessions-yield-tool.ts index 3b59c212805b..37189c2755f7 100644 --- a/src/agents/tools/sessions-yield-tool.ts +++ b/src/agents/tools/sessions-yield-tool.ts @@ -9,9 +9,15 @@ const NO_PENDING_CHILD_COMPLETION_MESSAGE = export type SessionsYieldClaimResult = | boolean + | { messageWaitRegistered: boolean } | { error: string } | { pendingChildren: readonly UnsettledRequesterChild[] }; export type SessionsYieldIntent = { waitFor?: "message"; acknowledgment?: string }; +export type SessionsYieldCallback = ( + message: string, + acknowledgment?: string, + messageWaitRegistered?: boolean, +) => Promise | void; function describePendingChild(child: UnsettledRequesterChild): string { const name = child.label ? `${child.label} (${child.childSessionKey})` : child.childSessionKey; @@ -70,7 +76,7 @@ export function createSessionsYieldTool(opts?: { claimYield?: ( intent?: SessionsYieldIntent, ) => SessionsYieldClaimResult | Promise; - onYield?: (message: string, acknowledgment?: string) => Promise | void; + onYield?: SessionsYieldCallback; }): AnyAgentTool { return { label: "Yield", @@ -114,10 +120,10 @@ export function createSessionsYieldTool(opts?: { pendingChildren: claim.pendingChildren, }); } - if (typeof claim === "object") { + if (typeof claim === "object" && "error" in claim) { return jsonResult({ status: "error", error: claim.error }); } - if (claim !== true) { + if (claim !== true && typeof claim !== "object") { // Advisory, not a failure: the model keeps the turn and nothing the user asked for failed. return jsonResult({ status: "nothing_pending", @@ -125,7 +131,11 @@ export function createSessionsYieldTool(opts?: { }); } // The runtime owns the actual pause/end-turn behavior; this tool records intent. - await opts.onYield(message, acknowledgment); + await opts.onYield( + message, + acknowledgment, + typeof claim === "object" ? claim.messageWaitRegistered : undefined, + ); return jsonResult({ status: "yielded", ...(acknowledgment ? { acknowledgment } : {}), diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index f4a965293c60..f53c020e89d0 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -215,7 +215,11 @@ describe("resolveGatewayScopedTools", () => { requesterSessionKey: "agent:main:telegram:group:-100123", requesterTurnRunId: "run-123", }); - expect(onYield).toHaveBeenCalledWith("waiting on subagents", "I’m waiting on the subagents."); + expect(onYield).toHaveBeenCalledWith( + "waiting on subagents", + "I’m waiting on the subagents.", + undefined, + ); expect(toolResult.details).toEqual({ status: "yielded", acknowledgment: "I’m waiting on the subagents.",