fix(agents): retry silent subagent completion handoffs

This commit is contained in:
Vincent Koc
2026-06-23 05:48:01 +02:00
parent 54b2243de3
commit 68a1e00b73
4 changed files with 109 additions and 10 deletions

View File

@@ -76,6 +76,15 @@ flow:
- ref: env
- 120000
- call: reset
- set: alphaLabel
value:
expr: "env.providerMode === 'mock-openai' ? config.expectedChildLabels[0] : `${config.expectedChildLabels[0]}-${attempt}`"
- set: betaLabel
value:
expr: "env.providerMode === 'mock-openai' ? config.expectedChildLabels[1] : `${config.expectedChildLabels[1]}-${attempt}`"
- set: prompt
value:
expr: "`Subagent fanout synthesis check: delegate exactly two bounded subagents sequentially using sessions_spawn, not ACP.\nFirst spawn exactly one child with label ${alphaLabel} and task: verify that \\`HEARTBEAT.md\\` exists and reply exactly \\`ok\\` if it does. Wait for that child to finish.\nThen spawn exactly one child with label ${betaLabel} and task: verify that \\`repo/qa/scenarios/agents/subagent-fanout-synthesis.yaml\\` exists and reply exactly \\`ok\\` if it does. Wait for that child to finish.\nDo not spawn any more children after ${betaLabel} finishes.\nThen reply with exactly these two lines and nothing else:\nsubagent-1: ok\nsubagent-2: ok`"
- set: sessionKey
value:
expr: "`agent:qa:fanout:${attempt}:${randomUUID().slice(0, 8)}`"
@@ -85,7 +94,7 @@ flow:
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
ref: prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 90000)
- call: waitForAgentHistoryReply
@@ -110,10 +119,10 @@ flow:
expr: "Object.values(store).filter((entry) => entry.spawnedBy === sessionKey)"
- set: sawAlpha
value:
expr: "childRows.some((entry) => entry.label === config.expectedChildLabels[0])"
expr: "childRows.some((entry) => entry.label === alphaLabel)"
- set: sawBeta
value:
expr: "childRows.some((entry) => entry.label === config.expectedChildLabels[1])"
expr: "childRows.some((entry) => entry.label === betaLabel)"
- assert:
expr: "sawAlpha && sawBeta"
message:
@@ -159,10 +168,10 @@ flow:
expr: "Object.values(timeoutStore).filter((entry) => entry.spawnedBy === sessionKey)"
- set: timeoutSawAlpha
value:
expr: "timeoutChildRows.some((entry) => entry.label === config.expectedChildLabels[0])"
expr: "timeoutChildRows.some((entry) => entry.label === alphaLabel)"
- set: timeoutSawBeta
value:
expr: "timeoutChildRows.some((entry) => entry.label === config.expectedChildLabels[1])"
expr: "timeoutChildRows.some((entry) => entry.label === betaLabel)"
- set: timeoutSpawnRequests
value:
expr: "[...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))].filter((request) => request.plannedToolName === 'sessions_spawn' && /subagent fanout synthesis check/i.test(String(request.allInputText ?? '')))"

View File

@@ -1548,7 +1548,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
},
);
it("accepts session-only completion handoff when the in-process agent intentionally replies NO_REPLY", async () => {
it("accepts non-subagent session-only completion handoff when the in-process agent intentionally replies NO_REPLY", async () => {
const dispatchGatewayMethodInProcess = createInProcessGatewayMock({
result: {
payloads: [{ text: "NO_REPLY" }],
@@ -1572,6 +1572,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-local-silent",
sourceTool: "agent_harness_task",
});
expectRecordFields(result, {
@@ -1586,6 +1587,47 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
});
});
it("rejects session-only subagent completion handoff when the parent only replies NO_REPLY", async () => {
const dispatchGatewayMethodInProcess = createInProcessGatewayMock({
result: {
payloads: [{ text: "NO_REPLY" }],
},
});
testing.setDepsForTest({
dispatchGatewayMethodInProcess,
getRequesterSessionActivity: () => ({
sessionId: "requester-session-local",
isActive: false,
}),
getRuntimeConfig: () => ({}) as never,
});
const result = await deliverSubagentAnnouncement({
requesterSessionKey: "agent:main:local-session",
targetRequesterSessionKey: "agent:main:local-session",
triggerMessage: "child done",
steerMessage: "child done",
requesterIsSubagent: false,
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-local-subagent-silent",
sourceTool: "subagent_announce",
});
expectRecordFields(result, {
delivered: false,
path: "direct",
reason: "visible_reply_missing",
error: "completion agent did not produce a visible reply",
});
expectInProcessAgentParams(dispatchGatewayMethodInProcess, {
deliver: false,
channel: undefined,
to: undefined,
bestEffortDeliver: true,
});
});
it.each([
{
name: "accepted session spawn",

View File

@@ -667,6 +667,46 @@ function hasVisibleGatewayAgentPayload(response: unknown): boolean {
);
}
function hasVisibleNonSilentGatewayAgentPayload(response: unknown): boolean {
const result = getGatewayAgentResult(response);
if (!result) {
return false;
}
if (hasMessagingToolDeliveryEvidence(result)) {
return true;
}
const payloads = Array.isArray(result.payloads) ? result.payloads : [];
return payloads.some(isVisibleNonSilentGatewayAgentPayload);
}
function isVisibleNonSilentGatewayAgentPayload(payload: unknown): boolean {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
const record = payload as {
text?: unknown;
mediaUrl?: unknown;
mediaUrls?: unknown;
presentation?: unknown;
interactive?: unknown;
channelData?: unknown;
};
if (
record.mediaUrl ||
(Array.isArray(record.mediaUrls) && record.mediaUrls.length > 0) ||
record.presentation ||
record.interactive ||
record.channelData
) {
return true;
}
return (
typeof record.text === "string" &&
record.text.trim() !== "" &&
!isSilentReplyPayloadText(record.text, SILENT_REPLY_TOKEN)
);
}
function hasGatewayAgentMessagingToolDeliveryEvidence(response: unknown): boolean {
const result = getGatewayAgentResult(response);
return Boolean(result && hasMessagingToolDeliveryEvidence(result));
@@ -1613,13 +1653,21 @@ async function sendSubagentAnnounceDirectly(params: {
error: "completion agent did not use the message tool for message-tool-only delivery",
};
}
const hasVisibleCompletionReply =
hasVisibleNonSilentGatewayAgentPayload(directAnnounceResponse);
const hasCompletionSideEffect =
hasGatewayAgentCompletionSideEffectEvidence(directAnnounceResponse);
const hasIntentionalSilentCompletionReply =
hasIntentionalSilentGatewayAgentPayload(directAnnounceResponse);
const acceptsIntentionalSilentCompletion =
hasIntentionalSilentCompletionReply && !isSubagentCompletion;
if (
params.expectsCompletionMessage &&
!shouldDeliverAgentFinal &&
!requiresMessageToolDelivery &&
!hasVisibleGatewayAgentPayload(directAnnounceResponse) &&
!hasGatewayAgentCompletionSideEffectEvidence(directAnnounceResponse) &&
!hasIntentionalSilentGatewayAgentPayload(directAnnounceResponse)
!hasVisibleCompletionReply &&
!hasCompletionSideEffect &&
!acceptsIntentionalSilentCompletion
) {
return {
delivered: false,

View File

@@ -93,7 +93,7 @@ function buildAnnounceReplyInstruction(params: {
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: ${SILENT_REPLY_TOKEN}.`;
}
if (params.expectsCompletionMessage) {
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. Keep this internal context private (don't mention system/log/stats/session details or announce type). Reply ONLY: ${SILENT_REPLY_TOKEN} when no user-facing update is needed.`;
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. Keep this internal context private (don't mention system/log/stats/session details or announce type). Reply ONLY: ${SILENT_REPLY_TOKEN} only when this exact result is already visible to the user in this same turn.`;
}
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the internal event text verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`;
}