fix(pi-embedded-runner): suppress incomplete-turn warning after clean messaging-tool delivery

The agent runner was surfacing a '⚠️ Agent couldn't generate a response'
warning even when the assistant had already sent user-visible content
through a messaging tool and the turn ended cleanly. Treat that path as
a successful delivery and skip the warning while keeping real failure
modes (tool errors, stopReason=error, interrupted tool use) intact.

Fixes #70396.
This commit is contained in:
Neerav Makwana
2026-04-22 21:26:31 -04:00
committed by Peter Steinberger
parent 93a1f5b3fa
commit 6cd9136f2d
3 changed files with 61 additions and 4 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Providers/SDK retry: cap long `Retry-After` sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.
- Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris.
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`/`end_turn`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396.
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
- Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.

View File

@@ -292,7 +292,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
);
});
it("does not retry reasoning-only turns after side effects", async () => {
it("does not retry or warn on reasoning-only turns when a messaging tool already delivered", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
@@ -300,7 +300,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
didSendViaMessagingTool: true,
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
stopReason: "stop",
provider: "openai",
model: "gpt-5.4",
content: [
@@ -322,8 +322,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.payloads?.[0]?.isError).toBe(true);
expect(result.payloads?.[0]?.text).toContain("verify before retrying");
expect(result.payloads).toBeUndefined();
});
it("does not retry reasoning-only turns when the assistant ended in error", async () => {
@@ -761,6 +760,49 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(incompleteTurnText).toBeNull();
});
it("suppresses the incomplete-turn warning when a messaging tool delivered and the turn ended cleanly", () => {
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 0,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
didSendViaMessagingTool: true,
lastAssistant: {
role: "assistant",
stopReason: "stop",
provider: "ollama",
model: "kimi-k2.6:cloud",
content: [],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(incompleteTurnText).toBeNull();
});
it("still surfaces the incomplete-turn warning after a messaging tool when the provider signalled an error", () => {
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 0,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
didSendViaMessagingTool: true,
lastAssistant: {
role: "assistant",
stopReason: "error",
provider: "ollama",
model: "kimi-k2.6:cloud",
errorMessage: "provider failed mid-turn",
content: [],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(incompleteTurnText).toContain("verify before retrying");
});
it("does not retry reasoning-only GPT turns after side effects", () => {
const retryInstruction = resolveReasoningOnlyRetryInstruction({
provider: "openai",

View File

@@ -20,6 +20,7 @@ type IncompleteTurnAttempt = Pick<
| "currentAttemptAssistant"
| "yieldDetected"
| "didSendDeterministicApprovalPrompt"
| "didSendViaMessagingTool"
| "lastToolError"
| "lastAssistant"
| "replayMetadata"
@@ -179,6 +180,19 @@ export function resolveIncompleteTurnPayloadText(params: {
}
const stopReason = params.attempt.lastAssistant?.stopReason;
// If the assistant already delivered user-visible content via a messaging
// tool during this turn and ended cleanly (stopReason=stop), do not surface
// an incomplete-turn warning. The user has received the reply; a follow-up
// "couldn't generate a response" bubble is a false positive. Real failure
// modes (tool errors, provider stopReason=error, tool-use interruption)
// still fall through to the normal incomplete-turn paths below.
if (
params.attempt.didSendViaMessagingTool &&
!params.attempt.lastToolError &&
stopReason === "stop"
) {
return null;
}
const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({
hasAssistantVisibleText: params.payloadCount > 0,
lastAssistant: params.attempt.lastAssistant,