fix(agent): reject malformed model envelopes before tool execution

This commit is contained in:
134 2026-10-21 09:51:04 +08:00
parent c0ff503c85
commit bd27401b6c
3 changed files with 17 additions and 4 deletions

View File

@ -52,7 +52,7 @@ 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 (["AgentModelProtocolError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_JSONParseError"].includes(String(e?.name)) ||
if (["AgentModelProtocolError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_TypeValidationError", "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,8 +24,9 @@ export function toResponsesRequest(body: RecordValue): RecordValue {
}
export function responsesCompletion(payload: RecordValue): RecordValue {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new AgentModelProtocolError();
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");
Array.isArray(payload.output) && payload.output.some((item: RecordValue) => Array.isArray(item?.content) && 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 } }));
@ -42,10 +43,12 @@ export function completionResponse(payload: RecordValue, request: RecordValue) {
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();
if ((message.content != null && typeof message.content !== "string") || !Array.isArray(calls) || (!message.content && !calls.length)) throw new AgentModelProtocolError();
const allowed = new Set((request.tools || []).map((tool: RecordValue) => tool.function?.name));
const seenIds = new Set<string>();
for (const call of calls) {
if (!call.id || !allowed.has(call.function?.name) || typeof call.function?.arguments !== "string") throw new AgentModelProtocolError();
if (!call || typeof call.id !== "string" || !call.id || seenIds.has(call.id) || call.type !== "function" || !allowed.has(call.function?.name) || typeof call.function?.arguments !== "string") throw new AgentModelProtocolError();
seenIds.add(call.id);
try { const args = JSON.parse(call.function.arguments); if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error(); }
catch { throw new AgentModelProtocolError(); }
}

View File

@ -103,3 +103,13 @@ test("successful HTTP policy refusals are not retried as provider faults", () =>
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");
});
test("malformed protocol envelopes and duplicate tool IDs are classified before native execution", () => {
for (const value of [null, [], { status: "completed", output: {} }]) {
assert.throws(() => responsesCompletion(value as any), AgentModelProtocolError);
}
assert.throws(() => completionResponse({ choices: [{ finish_reason: "stop", message: { content: {} } }] }, {}), AgentModelProtocolError);
const call = { id: "duplicate", type: "function", function: { name: "readCanvas", arguments: "{}" } };
assert.throws(() => completionResponse({ choices: [{ finish_reason: "tool_calls", message: { tool_calls: [call, call] } }] }, { tools: [{ function: { name: "readCanvas" } }] }), AgentModelProtocolError);
});