Skip to content

Commit c2004fe

Browse files
fix(agents): surface blocked subagent completions (#80886)
Summary: - The PR adds shared blocked-liveness normalization, applies it to agent.wait, gateway dedupe, subagent registry, and announcement paths, and adds regression tests plus a changelog entry. - Reproducibility: yes. from source inspection: current main accepts blocked lifecycle/wait metadata as ok thr ... gateway wait and registry completion paths. I did not run a live provider overflow in this read-only pass. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(agents): normalize blocked wait completions - PR branch already contained follow-up commit before automerge: fix(agents): surface blocked subagent completions Validation: - ClawSweeper review passed for head 224785c. - Required merge gates passed before the squash merge. Prepared head SHA: 224785c Review: #80886 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 373e3fc commit c2004fe

13 files changed

Lines changed: 322 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121

2222
### Fixes
2323

24+
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
2425
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
2526
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
2627
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)

src/agents/run-wait.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,26 @@ describe("waitForAgentRun", () => {
251251
endedAt: 200,
252252
});
253253
});
254+
255+
it("normalizes blocked ok waits to errors", async () => {
256+
callGatewayMock.mockResolvedValue({
257+
status: "ok",
258+
startedAt: 100,
259+
endedAt: 200,
260+
livenessState: "blocked",
261+
error: "Context overflow: prompt too large for the model.",
262+
});
263+
264+
const result = await waitForAgentRun({ runId: "run-blocked", timeoutMs: 500 });
265+
266+
expect(result).toEqual({
267+
status: "error",
268+
error: "Context overflow: prompt too large for the model.",
269+
startedAt: 100,
270+
endedAt: 200,
271+
livenessState: "blocked",
272+
});
273+
});
254274
});
255275

256276
describe("waitForAgentRunAndReadUpdatedAssistantReply", () => {

src/agents/run-wait.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { callGateway } from "../gateway/call.js";
22
import { formatErrorMessage } from "../infra/errors.js";
3+
import { normalizeBlockedLivenessWaitStatus } from "../shared/agent-liveness.js";
34
import { extractAssistantText, stripToolMessages } from "./tools/chat-history-text.js";
45

56
type GatewayCaller = typeof callGateway;
@@ -47,9 +48,14 @@ function normalizeAgentWaitResult(
4748
status: AgentWaitResult["status"],
4849
wait?: RawAgentWaitResponse,
4950
): AgentWaitResult {
50-
return {
51+
const normalized = normalizeBlockedLivenessWaitStatus({
5152
status,
52-
error: typeof wait?.error === "string" ? wait.error : undefined,
53+
livenessState: wait?.livenessState,
54+
error: wait?.error,
55+
});
56+
return {
57+
status: normalized.status,
58+
error: normalized.error,
5359
startedAt: typeof wait?.startedAt === "number" ? wait.startedAt : undefined,
5460
endedAt: typeof wait?.endedAt === "number" ? wait.endedAt : undefined,
5561
stopReason: typeof wait?.stopReason === "string" ? wait.stopReason : undefined,

src/agents/subagent-announce-output.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
testing,
4+
applySubagentWaitOutcome,
45
buildChildCompletionFindings,
56
readSubagentOutput,
67
} from "./subagent-announce-output.js";
@@ -158,3 +159,26 @@ describe("buildChildCompletionFindings", () => {
158159
expect(findings).not.toContain("2. visible task");
159160
});
160161
});
162+
163+
describe("applySubagentWaitOutcome", () => {
164+
it("treats blocked ok wait snapshots as errors", () => {
165+
const applied = applySubagentWaitOutcome({
166+
wait: {
167+
status: "ok",
168+
startedAt: 100,
169+
endedAt: 150,
170+
livenessState: "blocked",
171+
error: "Context overflow: prompt too large for the model.",
172+
},
173+
outcome: undefined,
174+
});
175+
176+
expect(applied.outcome).toEqual({
177+
status: "error",
178+
error: "Context overflow: prompt too large for the model.",
179+
startedAt: 100,
180+
endedAt: 150,
181+
elapsedMs: 50,
182+
});
183+
});
184+
});

src/agents/subagent-announce-output.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
2+
import { formatBlockedLivenessError, isBlockedLivenessState } from "../shared/agent-liveness.js";
23
import { extractTextFromChatContent } from "../shared/chat-content.js";
34
import { wrapPromptDataBlock } from "./sanitize-for-prompt.js";
45
import {
@@ -376,7 +377,10 @@ export function applySubagentWaitOutcome(params: {
376377
}
377378
const waitError = typeof params.wait?.error === "string" ? params.wait.error : undefined;
378379
let outcome = next.outcome;
379-
if (params.wait?.status === "timeout") {
380+
// Capture/announcement callers can pass raw wait snapshots that bypass the primary normalizers.
381+
if (isBlockedLivenessState(params.wait?.livenessState)) {
382+
outcome = { status: "error", error: formatBlockedLivenessError(waitError) };
383+
} else if (params.wait?.status === "timeout") {
380384
outcome = { status: "timeout" };
381385
} else if (params.wait?.status === "error") {
382386
outcome = { status: "error", error: waitError };

src/agents/subagent-registry-run-manager.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
33
import { callGateway } from "../gateway/call.js";
44
import { createSubsystemLogger } from "../logging/subsystem.js";
55
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
6+
import { formatBlockedLivenessError, isBlockedLivenessState } from "../shared/agent-liveness.js";
67
import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
78
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
89
import type { DeliveryContext } from "../utils/delivery-context.types.js";
@@ -187,7 +188,8 @@ export function createSubagentRunManager(params: {
187188
if (wait.status === "pending") {
188189
return;
189190
}
190-
if (wait.yielded === true) {
191+
const waitBlocked = isBlockedLivenessState(wait.livenessState);
192+
if (wait.yielded === true && !waitBlocked) {
191193
if (
192194
markSubagentRunPausedAfterYield({
193195
entry,
@@ -199,11 +201,12 @@ export function createSubagentRunManager(params: {
199201
}
200202
return;
201203
}
202-
if (wait.status === "error" && isRecoverableAgentWaitError(wait.error)) {
204+
const waitStatus = waitBlocked ? "error" : wait.status;
205+
if (waitStatus === "error" && isRecoverableAgentWaitError(wait.error)) {
203206
scheduleWaitRetry(entry, "subagent wait interrupted; scheduling recovery", wait.error);
204207
return;
205208
}
206-
if (wait.status === "timeout") {
209+
if (waitStatus === "timeout") {
207210
const isTerminalWaitTimeout =
208211
typeof wait.endedAt === "number" ||
209212
typeof wait.stopReason === "string" ||
@@ -261,9 +264,10 @@ export function createSubagentRunManager(params: {
261264
entry.endedAt = Date.now();
262265
mutated = true;
263266
}
264-
const waitError = typeof wait.error === "string" ? wait.error : undefined;
267+
const rawWaitError = typeof wait.error === "string" ? wait.error : undefined;
268+
const waitError = waitBlocked ? formatBlockedLivenessError(rawWaitError) : rawWaitError;
265269
const baseOutcome: SubagentRunOutcome =
266-
wait.status === "error" ? { status: "error", error: waitError } : { status: "ok" };
270+
waitStatus === "error" ? { status: "error", error: waitError } : { status: "ok" };
267271
const outcome = withSubagentOutcomeTiming(baseOutcome, {
268272
startedAt: entry.startedAt,
269273
endedAt: entry.endedAt,
@@ -280,7 +284,7 @@ export function createSubagentRunManager(params: {
280284
endedAt: entry.endedAt,
281285
outcome,
282286
reason:
283-
wait.status === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE,
287+
waitStatus === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE,
284288
sendFarewell: true,
285289
accountId: entry.requesterOrigin?.accountId,
286290
triggerCleanup: true,

src/agents/subagent-registry.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,57 @@ describe("subagent registry seam flow", () => {
551551
expect(replacement?.endedAt).toBeUndefined();
552552
});
553553

554+
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
555+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
556+
if (request.method === "agent.wait") {
557+
return {
558+
status: "ok",
559+
startedAt: 100,
560+
endedAt: 250,
561+
livenessState: "blocked",
562+
error: "Context overflow: prompt too large for the model.",
563+
};
564+
}
565+
return {};
566+
});
567+
568+
mod.registerSubagentRun({
569+
runId: "run-blocked-wait",
570+
childSessionKey: "agent:main:subagent:child",
571+
requesterSessionKey: "agent:main:main",
572+
requesterDisplayKey: "main",
573+
task: "overflow wait",
574+
cleanup: "keep",
575+
expectsCompletionMessage: true,
576+
});
577+
578+
await waitForFast(() => {
579+
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
580+
});
581+
const announceParams = expectRecordFields(
582+
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "blocked wait announce"),
583+
{ childRunId: "run-blocked-wait" },
584+
"blocked wait announce params",
585+
);
586+
expectRecordFields(
587+
announceParams.outcome,
588+
{
589+
status: "error",
590+
error: "Context overflow: prompt too large for the model.",
591+
startedAt: 100,
592+
endedAt: 250,
593+
elapsedMs: 150,
594+
},
595+
"blocked wait announce outcome",
596+
);
597+
598+
const run = mod
599+
.listSubagentRunsForRequester("agent:main:main")
600+
.find((entry) => entry.runId === "run-blocked-wait");
601+
expect(run?.endedReason).toBe("subagent-error");
602+
expect(run?.outcome?.status).toBe("error");
603+
});
604+
554605
it("reconciles stale active runs from persisted terminal session state during sweep", async () => {
555606
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
556607
if (request.method === "agent.wait") {
@@ -793,6 +844,79 @@ describe("subagent registry seam flow", () => {
793844
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
794845
});
795846

847+
it("announces blocked lifecycle end events as errors instead of success", async () => {
848+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
849+
if (request.method === "agent.wait") {
850+
return { status: "pending" };
851+
}
852+
return {};
853+
});
854+
855+
mod.registerSubagentRun({
856+
runId: "run-blocked-end",
857+
childSessionKey: "agent:main:subagent:child",
858+
requesterSessionKey: "agent:main:main",
859+
requesterDisplayKey: "main",
860+
task: "overflow task",
861+
cleanup: "keep",
862+
expectsCompletionMessage: true,
863+
});
864+
865+
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
866+
mocks.onAgentEvent.mock.calls.length - 1
867+
] as unknown as
868+
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
869+
| undefined;
870+
const lifecycleHandler = lastOnAgentEventCall?.[0];
871+
expect(lifecycleHandler).toBeTypeOf("function");
872+
873+
lifecycleHandler?.({
874+
runId: "run-blocked-end",
875+
stream: "lifecycle",
876+
data: {
877+
phase: "start",
878+
startedAt: 10,
879+
},
880+
});
881+
lifecycleHandler?.({
882+
runId: "run-blocked-end",
883+
stream: "lifecycle",
884+
data: {
885+
phase: "end",
886+
startedAt: 10,
887+
endedAt: 20,
888+
livenessState: "blocked",
889+
error: "Context overflow: prompt too large for the model.",
890+
},
891+
});
892+
893+
await waitForFast(() => {
894+
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
895+
});
896+
const announceParams = expectRecordFields(
897+
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "blocked announce"),
898+
{ childRunId: "run-blocked-end" },
899+
"blocked announce params",
900+
);
901+
expectRecordFields(
902+
announceParams.outcome,
903+
{
904+
status: "error",
905+
error: "Context overflow: prompt too large for the model.",
906+
startedAt: 10,
907+
endedAt: 20,
908+
elapsedMs: 10,
909+
},
910+
"blocked announce outcome",
911+
);
912+
913+
const run = mod
914+
.listSubagentRunsForRequester("agent:main:main")
915+
.find((entry) => entry.runId === "run-blocked-end");
916+
expect(run?.endedReason).toBe("subagent-error");
917+
expect(run?.outcome?.status).toBe("error");
918+
});
919+
796920
it("preserves run-mode keep entries past SESSION_RUN_TTL_MS sweep", async () => {
797921
mod.registerSubagentRun({
798922
runId: "run-keep-survives-ttl",

src/agents/subagent-registry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ContextEngine, SubagentEndReason } from "../context-engine/types.j
66
import { callGateway } from "../gateway/call.js";
77
import { getAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
88
import { createSubsystemLogger } from "../logging/subsystem.js";
9+
import { formatBlockedLivenessError, isBlockedLivenessState } from "../shared/agent-liveness.js";
910
import { createLazyImportLoader, createLazyPromiseLoader } from "../shared/lazy-promise.js";
1011
import { importRuntimeModule } from "../shared/runtime-import.js";
1112
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
@@ -985,6 +986,8 @@ function ensureListener() {
985986
}
986987
const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now();
987988
const error = typeof evt.data?.error === "string" ? evt.data.error : undefined;
989+
const livenessState =
990+
typeof evt.data?.livenessState === "string" ? evt.data.livenessState : undefined;
988991
if (phase === "error") {
989992
schedulePendingLifecycleError({
990993
runId: evt.runId,
@@ -993,6 +996,23 @@ function ensureListener() {
993996
});
994997
return;
995998
}
999+
if (isBlockedLivenessState(livenessState)) {
1000+
clearPendingLifecycleError(evt.runId);
1001+
clearPendingLifecycleTimeout(evt.runId);
1002+
await completeSubagentRun({
1003+
runId: evt.runId,
1004+
endedAt,
1005+
outcome: {
1006+
status: "error",
1007+
error: formatBlockedLivenessError(error),
1008+
},
1009+
reason: SUBAGENT_ENDED_REASON_ERROR,
1010+
sendFarewell: true,
1011+
accountId: entry.requesterOrigin?.accountId,
1012+
triggerCleanup: true,
1013+
});
1014+
return;
1015+
}
9961016
if (evt.data?.aborted) {
9971017
schedulePendingLifecycleTimeout({
9981018
runId: evt.runId,

src/gateway/server-methods/agent-job.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { onAgentEvent } from "../../infra/agent-events.js";
2+
import { formatBlockedLivenessError, isBlockedLivenessState } from "../../shared/agent-liveness.js";
23
import { setSafeTimeout } from "../../utils/timer-delay.js";
34

45
const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
@@ -140,12 +141,14 @@ function createSnapshotFromLifecycleEvent(params: {
140141
const error = typeof data?.error === "string" ? data.error : undefined;
141142
const stopReason = typeof data?.stopReason === "string" ? data.stopReason : undefined;
142143
const livenessState = typeof data?.livenessState === "string" ? data.livenessState : undefined;
144+
const blocked = isBlockedLivenessState(livenessState);
145+
const status = phase === "error" || blocked ? "error" : data?.aborted ? "timeout" : "ok";
143146
return {
144147
runId,
145-
status: phase === "error" ? "error" : data?.aborted ? "timeout" : "ok",
148+
status,
146149
startedAt,
147150
endedAt,
148-
error,
151+
error: blocked ? formatBlockedLivenessError(error) : error,
149152
stopReason,
150153
livenessState,
151154
...(data?.yielded === true ? { yielded: true } : {}),

0 commit comments

Comments
 (0)