-
Notifications
You must be signed in to change notification settings - Fork 853
fix(adapters): frame AgentRouter first messages to pass the language filter #2082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7979903
1f4e047
286cbd2
c49fed6
879dbb0
0013b23
e2d4621
f60a045
19986ca
110ef57
e97fb26
8c1abc4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -816,6 +816,53 @@ function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return normalized; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * AgentRouter's gateway applies a language filter to the FIRST user message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * content and hard-fails non-English prompts with 400 content-blocked | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * (#2074). Prepending an explicit English instruction frame lets the filter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * pass while the model still answers in the user's language — the frame says | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * "respond in the appropriate language", it does not force English output. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const AGR_PREAMBLE_MARKER = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "[Instruction: Process the user request below and respond in the appropriate language.]"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function isAgentRouterBaseUrl(baseUrl: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new URL(baseUrl).hostname.includes("agentrouter"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Prepend the AgentRouter language frame to the first user message, once. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Idempotent: a message already carrying the marker is left untouched (retries | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * and replays must not stack frames). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function applyAgrLanguagePreamble(messages: unknown[]): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const firstUser = messages.find( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| m => typeof m === "object" && m !== null && (m as { role?: string }).role === "user", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) as { content?: unknown } | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!firstUser) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof firstUser.content === "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!firstUser.content.includes(AGR_PREAMBLE_MARKER)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| firstUser.content = `${AGR_PREAMBLE_MARKER}\n\n${firstUser.content}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (Array.isArray(firstUser.content)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const textPart = firstUser.content.find( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p => typeof p === "object" && p !== null && (p as { type?: string }).type === "text", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) as { text?: string } | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (textPart && typeof textPart.text === "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!textPart.text.includes(AGR_PREAMBLE_MARKER)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| textPart.text = `${AGR_PREAMBLE_MARKER}\n\n${textPart.text}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| firstUser.content.unshift({ type: "text", text: AGR_PREAMBLE_MARKER }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+848
to
+863
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Detect an existing frame only at the start of the text content. Lines 849 and 857 use Use Proposed fix- if (!firstUser.content.includes(AGR_PREAMBLE_MARKER)) {
+ if (!firstUser.content.startsWith(AGR_PREAMBLE_MARKER)) {
firstUser.content = `${AGR_PREAMBLE_MARKER}\n\n${firstUser.content}`;
}
...
- if (!textPart.text.includes(AGR_PREAMBLE_MARKER)) {
+ if (!textPart.text.startsWith(AGR_PREAMBLE_MARKER)) {
textPart.text = `${AGR_PREAMBLE_MARKER}\n\n${textPart.text}`;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long"): ProviderAdapter { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isOAuth = provider.authMode === "oauth"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const toolNames = buildToolNameTransforms(provider); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -833,6 +880,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isAgentRouterBaseUrl(provider.baseUrl)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| applyAgrLanguagePreamble(messages); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Primary image layer: resize/re-encode to fit Anthropic limits without dropping | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (anthropic-image-normalize.ts); the guard below remains the deterministic backstop. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // imageTierBias > 0 = upstream-413 tightened retry (030): start every image one tier lower. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { | ||
| AGR_PREAMBLE_MARKER, | ||
| applyAgrLanguagePreamble, | ||
| isAgentRouterBaseUrl, | ||
| } from "../src/adapters/anthropic"; | ||
|
|
||
| describe("AgentRouter language preamble (#2074)", () => { | ||
| test("detects AgentRouter base URLs by hostname", () => { | ||
| expect(isAgentRouterBaseUrl("https://agentrouter.org")).toBe(true); | ||
| expect(isAgentRouterBaseUrl("https://agentrouter.org/v1")).toBe(true); | ||
| expect(isAgentRouterBaseUrl("https://api.anthropic.com")).toBe(false); | ||
| expect(isAgentRouterBaseUrl("not a url")).toBe(false); | ||
| }); | ||
|
|
||
| test("prepends the frame to a string first user message", () => { | ||
| const messages = [ | ||
| { role: "user", content: "responda apenas: OK" }, | ||
| { role: "assistant", content: "OK" }, | ||
| ]; | ||
| applyAgrLanguagePreamble(messages); | ||
| const first = messages[0] as { content: string }; | ||
| expect(first.content.startsWith(AGR_PREAMBLE_MARKER)).toBe(true); | ||
| expect(first.content).toContain("responda apenas: OK"); | ||
| }); | ||
|
|
||
| test("prepends into the first text part of structured content", () => { | ||
| const messages = [ | ||
| { role: "user", content: [{ type: "image", source: {} }, { type: "text", text: "hola" }] }, | ||
| ]; | ||
| applyAgrLanguagePreamble(messages); | ||
| const parts = (messages[0] as { content: Array<{ type: string; text?: string }> }).content; | ||
| const text = parts.find(p => p.type === "text"); | ||
| expect(text?.text?.startsWith(AGR_PREAMBLE_MARKER)).toBe(true); | ||
| expect(text?.text).toContain("hola"); | ||
| }); | ||
|
|
||
| test("inserts a text part when the first message has none", () => { | ||
| const messages = [{ role: "user", content: [{ type: "image", source: {} }] }]; | ||
| applyAgrLanguagePreamble(messages); | ||
| const parts = (messages[0] as { content: Array<{ type: string; text?: string }> }).content; | ||
| expect(parts[0]?.type).toBe("text"); | ||
| expect(parts[0]?.text).toContain(AGR_PREAMBLE_MARKER); | ||
| }); | ||
|
|
||
| test("is idempotent — replays do not stack frames", () => { | ||
| const messages = [{ role: "user", content: "bonjour" }]; | ||
| applyAgrLanguagePreamble(messages); | ||
| applyAgrLanguagePreamble(messages); | ||
| const content = (messages[0] as { content: string }).content; | ||
| expect(content.split(AGR_PREAMBLE_MARKER).length - 1).toBe(1); | ||
| }); | ||
|
|
||
| test("no user message is a no-op", () => { | ||
| const messages = [{ role: "assistant", content: "hi" }]; | ||
| applyAgrLanguagePreamble(messages); | ||
| expect(messages).toEqual([{ role: "assistant", content: "hi" }]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Restrict AgentRouter detection to an approved hostname boundary.
Line 831 enables the preamble for any hostname that contains
"agentrouter". For example,notagentrouter.exampleandagentrouter.org.attacker.examplematch. This violates the requirement to apply the transformation only to AgentRouter providers and changes prompts sent to unrelated providers.Use an allowlist or an exact AgentRouter domain boundary. Add negative tests for substring and suffix-confusion hostnames.
Proposed fix
export function isAgentRouterBaseUrl(baseUrl: string): boolean { try { - return new URL(baseUrl).hostname.includes("agentrouter"); + const hostname = new URL(baseUrl).hostname; + return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org"); } catch { return false; } }📝 Committable suggestion
🤖 Prompt for AI Agents