Skip to content

Commit 9e9df8f

Browse files
committed
fix(agents): centralize media delivery evidence
1 parent 40d50cb commit 9e9df8f

15 files changed

Lines changed: 256 additions & 106 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131
- Providers/MiniMax: derive Coding Plan usage polling from the configured MiniMax base URL, so global setups no longer query the CN usage host. Fixes #65054. Thanks @sixone74 and @Yanhu007.
3232
- Sessions: reject `sessions_send` targets that resolve to thread-scoped chat sessions, so inter-agent coordination cannot be injected into active human-facing Slack or Discord threads. Fixes #52496. Thanks @barry-p5cc.
3333
- Subagents: honor `sessions_spawn` with `expectsCompletionMessage: false` by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw.
34+
- Media/completions: treat media-only message-tool sends as delivered async completion output, avoiding duplicate raw `MEDIA:` fallback posts after video or music generation finishes.
3435
- Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc.
3536
- Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se.
3637
- ACP/Discord: suppress completion announce delivery for inline thread-bound ACP session runs, so Discord thread-bound ACP replies are not delivered twice. Fixes #60780. Thanks @solavrc.

src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
160160
const didSendViaMessagingTool = overrides.didSendViaMessagingTool ?? false;
161161
const messagingToolSentTexts = overrides.messagingToolSentTexts ?? [];
162162
const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? [];
163+
const messagingToolSentTargets = overrides.messagingToolSentTargets ?? [];
163164
const successfulCronAdds = overrides.successfulCronAdds;
164165
return {
165166
aborted: false,
@@ -182,12 +183,13 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
182183
didSendViaMessagingTool,
183184
messagingToolSentTexts,
184185
messagingToolSentMediaUrls,
186+
messagingToolSentTargets,
185187
successfulCronAdds,
186188
}),
187189
didSendViaMessagingTool,
188190
messagingToolSentTexts,
189191
messagingToolSentMediaUrls,
190-
messagingToolSentTargets: [],
192+
messagingToolSentTargets,
191193
cloudCodeAssistFormatError: false,
192194
itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 },
193195
...overrides,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
type AgentPayloadLike = {
2+
text?: unknown;
3+
mediaUrl?: unknown;
4+
mediaUrls?: unknown;
5+
presentation?: unknown;
6+
interactive?: unknown;
7+
channelData?: unknown;
8+
isError?: unknown;
9+
isReasoning?: unknown;
10+
};
11+
12+
export type AgentDeliveryEvidence = {
13+
payloads?: unknown;
14+
didSendViaMessagingTool?: unknown;
15+
messagingToolSentTexts?: unknown;
16+
messagingToolSentMediaUrls?: unknown;
17+
messagingToolSentTargets?: unknown;
18+
successfulCronAdds?: unknown;
19+
meta?: {
20+
toolSummary?: {
21+
calls?: unknown;
22+
};
23+
};
24+
};
25+
26+
function hasNonEmptyString(value: unknown): value is string {
27+
return typeof value === "string" && value.trim().length > 0;
28+
}
29+
30+
function hasNonEmptyArray(value: unknown): boolean {
31+
return Array.isArray(value) && value.length > 0;
32+
}
33+
34+
function hasNonEmptyStringArray(value: unknown): boolean {
35+
return Array.isArray(value) && value.some(hasNonEmptyString);
36+
}
37+
38+
function hasPositiveNumber(value: unknown): boolean {
39+
return typeof value === "number" && Number.isFinite(value) && value > 0;
40+
}
41+
42+
export function getGatewayAgentResult(response: unknown): AgentDeliveryEvidence | null {
43+
if (!response || typeof response !== "object" || !("result" in response)) {
44+
return null;
45+
}
46+
const result = (response as { result?: unknown }).result;
47+
if (!result || typeof result !== "object") {
48+
return null;
49+
}
50+
return result as AgentDeliveryEvidence;
51+
}
52+
53+
export function hasVisibleAgentPayload(
54+
result: Pick<AgentDeliveryEvidence, "payloads">,
55+
options: { includeErrorPayloads?: boolean; includeReasoningPayloads?: boolean } = {},
56+
): boolean {
57+
const payloads = result.payloads;
58+
if (!Array.isArray(payloads)) {
59+
return false;
60+
}
61+
return payloads.some((payload) => {
62+
if (!payload || typeof payload !== "object") {
63+
return false;
64+
}
65+
const record = payload as AgentPayloadLike;
66+
if (options.includeErrorPayloads === false && record.isError === true) {
67+
return false;
68+
}
69+
if (options.includeReasoningPayloads === false && record.isReasoning === true) {
70+
return false;
71+
}
72+
return Boolean(
73+
hasNonEmptyString(record.text) ||
74+
hasNonEmptyString(record.mediaUrl) ||
75+
hasNonEmptyStringArray(record.mediaUrls) ||
76+
record.presentation ||
77+
record.interactive ||
78+
record.channelData,
79+
);
80+
});
81+
}
82+
83+
export function hasMessagingToolDeliveryEvidence(result: AgentDeliveryEvidence): boolean {
84+
return (
85+
result.didSendViaMessagingTool === true || hasCommittedMessagingToolDeliveryEvidence(result)
86+
);
87+
}
88+
89+
export function hasCommittedMessagingToolDeliveryEvidence(
90+
result: Pick<
91+
AgentDeliveryEvidence,
92+
"messagingToolSentTexts" | "messagingToolSentMediaUrls" | "messagingToolSentTargets"
93+
>,
94+
): boolean {
95+
return (
96+
hasNonEmptyStringArray(result.messagingToolSentTexts) ||
97+
hasNonEmptyStringArray(result.messagingToolSentMediaUrls) ||
98+
hasNonEmptyArray(result.messagingToolSentTargets)
99+
);
100+
}
101+
102+
export function hasOutboundDeliveryEvidence(result: AgentDeliveryEvidence): boolean {
103+
return (
104+
hasMessagingToolDeliveryEvidence(result) ||
105+
hasPositiveNumber(result.successfulCronAdds) ||
106+
hasPositiveNumber(result.meta?.toolSummary?.calls)
107+
);
108+
}

src/agents/pi-embedded-runner/result-fallback-classifier.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js";
22
import { isGpt5ModelId } from "../gpt5-prompt-overlay.js";
33
import type { ModelFallbackResultClassification } from "../model-fallback.js";
4+
import { hasOutboundDeliveryEvidence, hasVisibleAgentPayload } from "./delivery-evidence.js";
45
import type { EmbeddedPiRunResult } from "./types.js";
56

67
const EMPTY_TERMINAL_REPLY_RE = /Agent couldn't generate a response/i;
@@ -16,31 +17,6 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult {
1617
);
1718
}
1819

19-
function hasVisibleNonErrorPayload(result: EmbeddedPiRunResult): boolean {
20-
return (result.payloads ?? []).some((payload) => {
21-
if (!payload || payload.isError === true || payload.isReasoning === true) {
22-
return false;
23-
}
24-
const text = typeof payload.text === "string" ? payload.text.trim() : "";
25-
return (
26-
text.length > 0 ||
27-
Boolean(payload.mediaUrl) ||
28-
(Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0)
29-
);
30-
});
31-
}
32-
33-
function hasOutboundSideEffects(result: EmbeddedPiRunResult): boolean {
34-
return (
35-
result.didSendViaMessagingTool === true ||
36-
(result.messagingToolSentTexts?.length ?? 0) > 0 ||
37-
(result.messagingToolSentMediaUrls?.length ?? 0) > 0 ||
38-
(result.messagingToolSentTargets?.length ?? 0) > 0 ||
39-
(result.successfulCronAdds ?? 0) > 0 ||
40-
(result.meta.toolSummary?.calls ?? 0) > 0
41-
);
42-
}
43-
4420
function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean {
4521
return [result.meta.finalAssistantRawText, result.meta.finalAssistantVisibleText].some(
4622
(text) => typeof text === "string" && isSilentReplyPayloadText(text),
@@ -90,11 +66,14 @@ export function classifyEmbeddedPiRunResultForModelFallback(params: {
9066
params.result.meta.aborted ||
9167
params.hasDirectlySentBlockReply === true ||
9268
params.hasBlockReplyPipelineOutput === true ||
93-
hasVisibleNonErrorPayload(params.result)
69+
hasVisibleAgentPayload(params.result, {
70+
includeErrorPayloads: false,
71+
includeReasoningPayloads: false,
72+
})
9473
) {
9574
return null;
9675
}
97-
if (hasOutboundSideEffects(params.result)) {
76+
if (hasOutboundDeliveryEvidence(params.result)) {
9877
return null;
9978
}
10079

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
3+
import { hasCommittedMessagingToolDeliveryEvidence } from "./delivery-evidence.js";
34
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
45
import {
56
loadRunOverflowCompactionHarness,
@@ -18,7 +19,6 @@ import {
1819
DEFAULT_REASONING_ONLY_RETRY_LIMIT,
1920
EMPTY_RESPONSE_RETRY_INSTRUCTION,
2021
extractPlanningOnlyPlanDetails,
21-
hasCommittedUserVisibleToolDelivery,
2222
isLikelyExecutionAckPrompt,
2323
PLANNING_ONLY_RETRY_INSTRUCTION,
2424
REASONING_ONLY_RETRY_INSTRUCTION,
@@ -1345,24 +1345,34 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
13451345
expect(incompleteTurnText).toContain("verify before retrying");
13461346
});
13471347

1348-
it("does not treat empty committed messaging arrays as user-visible delivery", () => {
1348+
it("does not treat empty committed messaging arrays as delivery", () => {
13491349
expect(
1350-
hasCommittedUserVisibleToolDelivery({
1350+
hasCommittedMessagingToolDeliveryEvidence({
13511351
messagingToolSentTexts: [" "],
13521352
messagingToolSentMediaUrls: [],
13531353
}),
13541354
).toBe(false);
13551355
});
13561356

1357-
it("treats committed messaging media as user-visible delivery", () => {
1357+
it("treats committed messaging media as delivery", () => {
13581358
expect(
1359-
hasCommittedUserVisibleToolDelivery({
1359+
hasCommittedMessagingToolDeliveryEvidence({
13601360
messagingToolSentTexts: [],
13611361
messagingToolSentMediaUrls: ["file:///tmp/render.png"],
13621362
}),
13631363
).toBe(true);
13641364
});
13651365

1366+
it("treats committed messaging targets as delivery", () => {
1367+
expect(
1368+
hasCommittedMessagingToolDeliveryEvidence({
1369+
messagingToolSentTexts: [],
1370+
messagingToolSentMediaUrls: [],
1371+
messagingToolSentTargets: [{ tool: "message", provider: "slack", to: "channel-1" }],
1372+
}),
1373+
).toBe(true);
1374+
});
1375+
13661376
it("treats committed messaging text as replay-invalid side effect metadata", () => {
13671377
expect(
13681378
buildAttemptReplayMetadata({
@@ -1385,6 +1395,18 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
13851395
).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
13861396
});
13871397

1398+
it("treats committed messaging targets as replay-invalid side effect metadata", () => {
1399+
expect(
1400+
buildAttemptReplayMetadata({
1401+
toolMetas: [],
1402+
didSendViaMessagingTool: false,
1403+
messagingToolSentTexts: [],
1404+
messagingToolSentMediaUrls: [],
1405+
messagingToolSentTargets: [{ tool: "message", provider: "slack", to: "channel-1" }],
1406+
}),
1407+
).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
1408+
});
1409+
13881410
it("leaves committed delivery plus tool errors to the tool-error payload path", () => {
13891411
const incompleteTurnText = resolveIncompleteTurnPayloadText({
13901412
payloadCount: 0,

src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function makeAttemptResult(
3737
const didSendViaMessagingTool = overrides.didSendViaMessagingTool ?? false;
3838
const messagingToolSentTexts = overrides.messagingToolSentTexts ?? [];
3939
const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? [];
40+
const messagingToolSentTargets = overrides.messagingToolSentTargets ?? [];
4041
const successfulCronAdds = overrides.successfulCronAdds;
4142
return {
4243
aborted: false,
@@ -58,6 +59,7 @@ export function makeAttemptResult(
5859
didSendViaMessagingTool,
5960
messagingToolSentTexts,
6061
messagingToolSentMediaUrls,
62+
messagingToolSentTargets,
6163
successfulCronAdds,
6264
}),
6365
itemLifecycle: {
@@ -68,7 +70,7 @@ export function makeAttemptResult(
6870
didSendViaMessagingTool,
6971
messagingToolSentTexts,
7072
messagingToolSentMediaUrls,
71-
messagingToolSentTargets: [],
73+
messagingToolSentTargets,
7274
cloudCodeAssistFormatError: false,
7375
...overrides,
7476
};

src/agents/pi-embedded-runner/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js
8888
import { runPostCompactionSideEffects } from "./compaction-hooks.js";
8989
import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js";
9090
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
91+
import { hasMessagingToolDeliveryEvidence } from "./delivery-evidence.js";
9192
import { resolveEmbeddedRunFailureSignal } from "./failure-signal.js";
9293
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
9394
import { log } from "./logger.js";
@@ -1204,7 +1205,7 @@ export async function runEmbeddedPiAgent(
12041205
? sessionLastAssistant.errorMessage?.trim() || formattedAssistantErrorText
12051206
: undefined;
12061207
const canRestartForLiveSwitch =
1207-
!attempt.didSendViaMessagingTool &&
1208+
!hasMessagingToolDeliveryEvidence(attempt) &&
12081209
!attempt.didSendDeterministicApprovalPrompt &&
12091210
!attempt.lastToolError &&
12101211
(attempt.toolMetas?.length ?? 0) === 0 &&

0 commit comments

Comments
 (0)