FN-5832: surface unsupported message-role provider errors
Make unsupported provider message-role failures fail fast with actionable operator guidance. - detect provider errors that reject `messages.[n].role` values via a dedicated transient-error detector helper - classify unsupported message-role failures as operator-actionable/permanent to prevent retry loops - annotate `promptSessionAndCheck` failures with model/provider compatibility guidance for imported company-role agents - add regression tests for role-error detection, operator-actionable classification, and prompt-boundary error hinting - add a patch changeset for `@runfusion/fusion` Files changed: .changeset/fn-5832-unsupported-message-role.md | 5 +++ .../pi-prompt-session-and-check-recursion.test.ts | 24 +++++++++++++++ .../src/__tests__/transient-error-detector.test.ts | 36 ++++++++++++++++++++++ packages/engine/src/pi.ts | 8 +++++ packages/engine/src/transient-error-detector.ts | 14 ++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-5832 Fusion-Task-Lineage: b3f78f0e-d097-4cb7-a652-dc547f46fe1d
This commit is contained in:
5
.changeset/fn-5832-unsupported-message-role.md
Normal file
5
.changeset/fn-5832-unsupported-message-role.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Classify provider 400 errors for unsupported `messages.[n].role` values as operator-actionable agent errors, and annotate prompt-boundary failures with a clear model/provider compatibility hint. This stops invisible retry loops and makes misconfigured imported agent model/provider combinations fail fast with actionable diagnostics.
|
||||||
@@ -56,4 +56,28 @@ describe("promptSessionAndCheck recursion guard (FN-4930)", () => {
|
|||||||
}
|
}
|
||||||
expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining("failed to inspect transcript"));
|
expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining("failed to inspect transcript"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("annotates unsupported message-role provider errors with actionable hint", async () => {
|
||||||
|
const { promptSessionAndCheck } = await import("../pi.js");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
errorMessage: "",
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
prompt: vi.fn(async () => {
|
||||||
|
state.errorMessage =
|
||||||
|
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'";
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await expect(promptSessionAndCheck(session, "hello")).rejects.toThrow(
|
||||||
|
/developer is not one of \['system', 'assistant', 'user', 'tool', 'function'\] - 'messages\.\[0\]\.role'/,
|
||||||
|
);
|
||||||
|
await expect(promptSessionAndCheck(session, "hello")).rejects.toThrow(
|
||||||
|
/Operator action required: this agent's configured model\/provider rejected a message role\./,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
extractMissingModulePath,
|
extractMissingModulePath,
|
||||||
isOperatorActionableAgentError,
|
isOperatorActionableAgentError,
|
||||||
isStaleWorktreeModuleResolutionError,
|
isStaleWorktreeModuleResolutionError,
|
||||||
|
isUnsupportedMessageRoleError,
|
||||||
TRANSIENT_ERROR_PATTERNS,
|
TRANSIENT_ERROR_PATTERNS,
|
||||||
} from "../transient-error-detector.js";
|
} from "../transient-error-detector.js";
|
||||||
import { isUsageLimitError } from "../usage-limit-detector.js";
|
import { isUsageLimitError } from "../usage-limit-detector.js";
|
||||||
@@ -269,6 +270,34 @@ describe("Transient Error Detector", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isUnsupportedMessageRoleError", () => {
|
||||||
|
it("returns true for the reported provider error", () => {
|
||||||
|
expect(
|
||||||
|
isUnsupportedMessageRoleError(
|
||||||
|
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for role/index/case variants", () => {
|
||||||
|
expect(
|
||||||
|
isUnsupportedMessageRoleError(
|
||||||
|
"assistant_role is not one of ['system','assistant','user','tool','function'] - 'messages.[3].role'",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isUnsupportedMessageRoleError(
|
||||||
|
"'MESSAGES.[9].ROLE' IS NOT ONE OF ['system','assistant']",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unrelated errors", () => {
|
||||||
|
expect(isUnsupportedMessageRoleError("socket hang up")).toBe(false);
|
||||||
|
expect(isUnsupportedMessageRoleError("invalid api key")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isOperatorActionableAgentError", () => {
|
describe("isOperatorActionableAgentError", () => {
|
||||||
it("returns true for credential/model/billing errors", () => {
|
it("returns true for credential/model/billing errors", () => {
|
||||||
expect(isOperatorActionableAgentError("invalid api key")).toBe(true);
|
expect(isOperatorActionableAgentError("invalid api key")).toBe(true);
|
||||||
@@ -278,6 +307,13 @@ describe("Transient Error Detector", () => {
|
|||||||
expect(isOperatorActionableAgentError("billing issue: quota exceeded")).toBe(true);
|
expect(isOperatorActionableAgentError("billing issue: quota exceeded")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns true for unsupported message-role errors", () => {
|
||||||
|
const message =
|
||||||
|
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'";
|
||||||
|
expect(isOperatorActionableAgentError(message)).toBe(true);
|
||||||
|
expect(classifyError(message)).toBe("permanent");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns false for transient network errors", () => {
|
it("returns false for transient network errors", () => {
|
||||||
expect(isOperatorActionableAgentError("socket hang up")).toBe(false);
|
expect(isOperatorActionableAgentError("socket hang up")).toBe(false);
|
||||||
expect(isOperatorActionableAgentError("upstream connect error")).toBe(false);
|
expect(isOperatorActionableAgentError("upstream connect error")).toBe(false);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
|
|||||||
import type { SystemPromptLayers } from "./prompt-layers.js";
|
import type { SystemPromptLayers } from "./prompt-layers.js";
|
||||||
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js";
|
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js";
|
||||||
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
|
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
|
||||||
|
import { isUnsupportedMessageRoleError } from "./transient-error-detector.js";
|
||||||
|
|
||||||
const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
|
const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
|
||||||
const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
|
const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
|
||||||
@@ -388,6 +389,13 @@ export async function promptSessionAndCheck(session: AgentSession, prompt: strin
|
|||||||
piLog.warn(`pi state error — Codex WebSocket transport drop (model=${modelDesc}): ${stateError}`);
|
piLog.warn(`pi state error — Codex WebSocket transport drop (model=${modelDesc}): ${stateError}`);
|
||||||
throw new Error(`${stateError} (model=${modelDesc})`);
|
throw new Error(`${stateError} (model=${modelDesc})`);
|
||||||
}
|
}
|
||||||
|
if (isUnsupportedMessageRoleError(stateError)) {
|
||||||
|
const modelDesc = describeModel(session);
|
||||||
|
const hint =
|
||||||
|
"Operator action required: this agent's configured model/provider rejected a message role. Check the agent model selection and provider compatibility (imported non-default 'company' agents may default to an incompatible model+provider combination).";
|
||||||
|
piLog.error(`pi state error — unsupported message role (model=${modelDesc}): ${stateError}`);
|
||||||
|
throw new Error(`${stateError} (model=${modelDesc}). ${hint}`);
|
||||||
|
}
|
||||||
throw new Error(stateError);
|
throw new Error(stateError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ export function extractMissingModulePath(errorMessage: string): string | null {
|
|||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNSUPPORTED_MESSAGE_ROLE_PATTERN = /\bmessages\.\[\d+\]\.role\b[\s\S]*\bis not one of\b|\bis not one of\b[\s\S]*\bmessages\.\[\d+\]\.role\b/i;
|
||||||
|
|
||||||
|
export function isUnsupportedMessageRoleError(errorMessage: string): boolean {
|
||||||
|
if (!errorMessage || typeof errorMessage !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return UNSUPPORTED_MESSAGE_ROLE_PATTERN.test(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
const OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS: RegExp[] = [
|
const OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS: RegExp[] = [
|
||||||
/invalid api key/i,
|
/invalid api key/i,
|
||||||
/authentication failed/i,
|
/authentication failed/i,
|
||||||
@@ -210,5 +219,8 @@ export function isOperatorActionableAgentError(errorMessage: string): boolean {
|
|||||||
if (!errorMessage || typeof errorMessage !== "string") {
|
if (!errorMessage || typeof errorMessage !== "string") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
|
return (
|
||||||
|
isUnsupportedMessageRoleError(errorMessage) ||
|
||||||
|
OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user