← All posts
pi.dev 重试插件(检查 API 空返回)
我用 pi(一个终端里的 AI 编程助手)干活时遇到个怪事:跑到一半它悄无声息地停了,没有报错也没有输出。我明明把自动重试次数配到了 999 次,为什么一次错误就把它放倒了?翻 session 日志才发现,它其实重试过——但最后一次请求时,我的 API 网关出了故障,返回了一个 HTTP 200 的空响应。pi 判断要不要重试只看响应的结束标记是"成功"还是"错误",从不检查内容;空响应的标记是"成功",于是重试机制认为一切正常,直接收工。999 次的预算,压根没有机会用。这个 extension 就是补这个盲区:在每条响应落地时站岗检查,遇到"标记成功但内容为空"的假成功,就把它改标成错误,让重试机制重新接管。代码如下,放进 ~/.pi/agent/extensions/ 即可自动加载。
/**
* Empty Response Retry Extension
*
* Problem: a broken gateway can return HTTP 200 with an empty stream. Pi
* parses it as a successful turn: { stopReason: "stop", content: [] }.
* Pi's retry check only looks at stopReason (never at content), so it
* treats this as success and the agent loop ends with an empty reply.
*
* Fix: the message_end hook can replace the finalized message BEFORE pi's
* retry check runs. For a "stop" message with completely empty content,
* we swap the label — stopReason "stop" → "error", plus an errorMessage
* starting with "Provider returned error" to match pi-ai's retryable-error
* regex — and pi's own retry machinery takes over (backoff, resend, TUI
* progress). Content is never touched.
*
* Circuit breaker: after MAX_EMPTY_RETRIES consecutive empty responses we
* attach a non-retryable error instead, so the turn fails loudly rather
* than retrying forever. Counter resets on each new user prompt.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { AssistantMessage } from "@earendil-works/pi-ai";
/** How many consecutive empty responses get converted into retryable errors. */
const MAX_EMPTY_RETRIES = 4;
/**
* True if this assistant message is a degenerate "successful but empty"
* response: finished normally, yet carries no tool call and no non-whitespace
* text/thinking. Such messages are essentially never legitimate in an agent
* turn and indicate a broken gateway/upstream.
*/
function isDegenerateEmptyResponse(msg: AssistantMessage): boolean {
if (msg.stopReason !== "stop") return false;
return msg.content.every((block) => {
switch (block.type) {
case "toolCall":
return false; // a tool call means real content
case "text":
return block.text.trim().length === 0;
case "thinking":
return block.thinking.trim().length === 0;
default:
return false; // unknown block type → treat as content
}
});
}
export default function (pi: ExtensionAPI) {
let consecutiveEmpty = 0;
// Fresh budget for each new user prompt (retries within a run don't
// re-fire this event, so the counter survives across the retries of
// a single turn but resets when the user starts a new turn).
pi.on("before_agent_start", async () => {
consecutiveEmpty = 0;
});
pi.on("session_start", async () => {
consecutiveEmpty = 0;
});
pi.on("message_end", async (event, _ctx) => {
const msg = event.message;
if (msg.role !== "assistant") return;
if (!isDegenerateEmptyResponse(msg)) {
consecutiveEmpty = 0;
return;
}
consecutiveEmpty++;
const tokens = msg.usage?.totalTokens ?? 0;
if (consecutiveEmpty <= MAX_EMPTY_RETRIES) {
// Relabel as a retryable error; pi's built-in retry takes it from here.
return {
message: {
...msg,
stopReason: "error" as const,
errorMessage:
`Provider returned error: empty response ` +
`(stream completed successfully but contained no content, ${tokens} tokens). ` +
`Treating as a transient gateway failure. ` +
`[empty-response-retry ${consecutiveEmpty}/${MAX_EMPTY_RETRIES}]`,
},
};
}
// Cap reached: convert to a *non-retryable* error (message deliberately
// avoids pi-ai's retryable patterns) so the turn fails loudly instead of
// looping forever or silently accepting the empty response.
return {
message: {
...msg,
stopReason: "error" as const,
errorMessage:
`Gateway returned an empty response ${consecutiveEmpty} times in a row; giving up. ` +
`The gateway is likely masking upstream failures as successful empty replies. ` +
`Check gateway/upstream health, then send your message again.`,
},
};
});
}