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. - 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. - 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. - 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. - 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. - 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. - 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); mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce( mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({ makeAttemptResult({
@@ -300,7 +300,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
didSendViaMessagingTool: true, didSendViaMessagingTool: true,
lastAssistant: { lastAssistant: {
role: "assistant", role: "assistant",
stopReason: "end_turn", stopReason: "stop",
provider: "openai", provider: "openai",
model: "gpt-5.4", model: "gpt-5.4",
content: [ content: [
@@ -322,8 +322,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
}); });
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads).toBeUndefined();
expect(result.payloads?.[0]?.text).toContain("verify before retrying");
}); });
it("does not retry reasoning-only turns when the assistant ended in error", async () => { 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(); 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", () => { it("does not retry reasoning-only GPT turns after side effects", () => {
const retryInstruction = resolveReasoningOnlyRetryInstruction({ const retryInstruction = resolveReasoningOnlyRetryInstruction({
provider: "openai", provider: "openai",

View File

@@ -20,6 +20,7 @@ type IncompleteTurnAttempt = Pick<
| "currentAttemptAssistant" | "currentAttemptAssistant"
| "yieldDetected" | "yieldDetected"
| "didSendDeterministicApprovalPrompt" | "didSendDeterministicApprovalPrompt"
| "didSendViaMessagingTool"
| "lastToolError" | "lastToolError"
| "lastAssistant" | "lastAssistant"
| "replayMetadata" | "replayMetadata"
@@ -179,6 +180,19 @@ export function resolveIncompleteTurnPayloadText(params: {
} }
const stopReason = params.attempt.lastAssistant?.stopReason; 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({ const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({
hasAssistantVisibleText: params.payloadCount > 0, hasAssistantVisibleText: params.payloadCount > 0,
lastAssistant: params.attempt.lastAssistant, lastAssistant: params.attempt.lastAssistant,