fix(agent): distinguish empty upstream answers from policy refusals

This commit is contained in:
134 2026-10-21 09:49:06 +08:00
parent f0186a175e
commit c0ff503c85
4 changed files with 15 additions and 1 deletions

View File

@ -87,6 +87,7 @@ export async function analyzeRuntimeImages(params: {
maxRetries: 0,
abortSignal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
});
if (answer.finishReason === "content-filter") throw new Error("content policy rejection");
const raw = answer.text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
let review: z.infer<typeof reviewSchema>;
try { review = reviewSchema.parse(JSON.parse(raw)); } catch { throw new AgentModelProtocolError(); }

View File

@ -52,7 +52,8 @@ export function agentModelFault(error: unknown, signal?: AbortSignal): AgentMode
if (status === 404 || /model.not.found|invalid.model/.test(message)) return { reason: "model_unavailable", status };
if (status === 408) return { reason: "connection_or_timeout", status };
if (status && status >= 500) return { reason: "upstream_unavailable", status };
if (e?.name === "AgentModelProtocolError") return { reason: "invalid_response", status };
if (["AgentModelProtocolError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_JSONParseError"].includes(String(e?.name)) ||
/未返回可用的回答|未返回所要求的工具|empty.response|no.output.generated/.test(message)) return { reason: "invalid_response", status };
if (/timeout|timed out|fetch failed|econnreset|econnrefused|network|socket|terminated/.test(message) || e?.name === "TimeoutError") return { reason: "connection_or_timeout", status };
return e?.cause ? agentModelFault(e.cause, signal) : null;
}

View File

@ -24,6 +24,8 @@ export function toResponsesRequest(body: RecordValue): RecordValue {
}
export function responsesCompletion(payload: RecordValue): RecordValue {
if (/content.?policy|content.?filter|safety|moderation/.test(JSON.stringify(payload.error || {}).toLowerCase()) ||
payload.output?.some((item: RecordValue) => item.content?.some((part: RecordValue) => part.type === "refusal"))) throw new Error("content policy rejection");
if (payload.error || payload.status !== "completed" || !Array.isArray(payload.output)) throw new AgentModelProtocolError();
const calls = payload.output.filter((item: RecordValue) => item.type === "function_call")
.map((item: RecordValue) => ({ id: item.call_id, type: "function", function: { name: item.name, arguments: item.arguments } }));
@ -37,6 +39,7 @@ export function responsesCompletion(payload: RecordValue): RecordValue {
export function completionResponse(payload: RecordValue, request: RecordValue) {
const choice = payload?.choices?.[0];
const message = choice?.message;
if (choice?.finish_reason === "content_filter" || message?.refusal) throw new Error("content policy rejection");
if (payload?.error || !message || !["stop", "tool_calls", "function_call"].includes(choice.finish_reason)) throw new AgentModelProtocolError();
const calls = message.tool_calls || [];
if (!Array.isArray(calls) || (!message.content && !calls.length)) throw new AgentModelProtocolError();

View File

@ -94,3 +94,12 @@ test("complete valid replies become native SSE; truncated/invalid tool replies a
completion.choices[0].message.tool_calls[0].function.arguments = '{"broken"';
assert.throws(() => completionResponse(completion, request), AgentModelProtocolError);
});
test("successful HTTP policy refusals are not retried as provider faults", () => {
let rejection: unknown;
try { completionResponse({ choices: [{ finish_reason: "content_filter", message: { content: "" } }] }, {}); } catch (error) { rejection = error; }
assert(rejection); assert.equal(agentModelFault(rejection), null);
assert.throws(() => responsesCompletion({ status: "completed", output: [{ type: "message", content: [{ type: "refusal" }] }] }), /content policy/);
assert.equal(agentModelFault(Object.assign(new Error("No output generated"), { name: "AI_NoOutputGeneratedError" }))?.reason, "invalid_response");
});