mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix(agents): waiting subagents post a false "yielded without a continuation source" warning (#162306)
* fix(agents): recognize registered message waits after yield Carry registry acceptance of an explicit message wait through the yield callback and current attempt into continuation evidence. Avoid a synthetic missing-continuation reply for registered waits while preserving diagnostics for refused or absent registration. * test(agents): align yield callback expectations * test(gateway): include yield registration evidence in callback assertion
This commit is contained in:
parent
0ac1c241fb
commit
2d89ad083b
21 changed files with 193 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export type EmbeddedAttemptExecutionPhaseInput = {
|
|||
yieldDetected: boolean;
|
||||
yieldMessage: string | null;
|
||||
yieldAcknowledgment?: string;
|
||||
yieldMessageWaitRegistered?: boolean;
|
||||
};
|
||||
setToolSearchCatalogExecutor: (
|
||||
executor: ReturnType<typeof prepareEmbeddedAttemptStream>["toolSearchCatalogExecutor"],
|
||||
|
|
|
|||
|
|
@ -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<PromptErrorInput> = {}): 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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> | null;
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
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<string, unknown>;
|
||||
},
|
||||
options?: { triggerTurn?: boolean },
|
||||
) => Promise<void>;
|
||||
},
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> | void;
|
||||
onYield?: SessionsYieldCallback;
|
||||
claimYieldCompletion?: () => boolean | Promise<boolean>;
|
||||
/** Records hot-path tool-prep stages for reply startup diagnostics. */
|
||||
recordToolPrepStage?: (name: string) => void;
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export function createSubagentRegistryPublicApi(config: {
|
|||
};
|
||||
assertCurrent();
|
||||
await restoreOnce(stateContext);
|
||||
await markSubagentMessageWaitInRuns({
|
||||
return await markSubagentMessageWaitInRuns({
|
||||
...params,
|
||||
runs,
|
||||
context: stateContext,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export async function markSubagentMessageWaitInRuns(params: {
|
|||
context: OpenClawStateWorkerContext;
|
||||
assertCurrent: () => void;
|
||||
persist: Parameters<typeof publishSubagentRunPostimages>[0]["persist"];
|
||||
}): Promise<void> {
|
||||
}): Promise<boolean> {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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> | 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<SessionsYieldClaimResult>;
|
||||
onYield?: (message: string, acknowledgment?: string) => Promise<void> | 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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue