diff --git a/lib/ai/runtime/agent-model-failover.ts b/lib/ai/runtime/agent-model-failover.ts index 6b16e4c85..8a609e42d 100644 --- a/lib/ai/runtime/agent-model-failover.ts +++ b/lib/ai/runtime/agent-model-failover.ts @@ -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; diff --git a/lib/ai/zcode-agent/model-gateway-protocol.ts b/lib/ai/zcode-agent/model-gateway-protocol.ts index 4373485c3..38d5ed678 100644 --- a/lib/ai/zcode-agent/model-gateway-protocol.ts +++ b/lib/ai/zcode-agent/model-gateway-protocol.ts @@ -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(); 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(); } } diff --git a/tests/integration/agent-multimodal-failover.test.ts b/tests/integration/agent-multimodal-failover.test.ts index 0628ddac8..87f1e83ea 100644 --- a/tests/integration/agent-multimodal-failover.test.ts +++ b/tests/integration/agent-multimodal-failover.test.ts @@ -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); +});