← 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.`,
			},
		};
	});
}