Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/agents/createFaceDetectionAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ToolLoopAgent, Output, stepCountIs } from "ai";
import { z } from "zod";

const faceDetectionSchema = z.object({
hasFace: z.boolean(),
});

const instructions = `You classify images as face guides or not.

A face guide is a headshot or portrait photo on a plain or white background, used for face-swapping in AI image generation. It shows a person's face clearly as the primary subject.

These are NOT face guides:
- Playlist covers or album art (even if they show a person)
- Promotional graphics with text overlays
- Concert photos or action shots
- Logos or branded images
- Any image where the face is not the sole focus on a clean background

Return hasFace: true ONLY for face guide images (headshots on plain backgrounds).
Return hasFace: false for everything else.`;

/**
* Creates a ToolLoopAgent configured for face guide detection in images.
* Uses Output.object with a Zod schema for structured boolean response.
*
* @returns A configured ToolLoopAgent using Google Gemini via AI Gateway.
*/
export function createFaceDetectionAgent() {
return new ToolLoopAgent({
model: "google/gemini-3.1-flash-lite-preview",
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag AI Slop and Fabricated Changes

The PR description claims this change uses Gemini 2.5 Flash, but the code actually references google/gemini-3.1-flash-lite-preview — a model identifier that doesn't appear anywhere else in the codebase and contradicts the PR's own stated intent. If this model ID is fabricated or hallucinated, the agent will fail at runtime. Please verify the model identifier is valid for your AI gateway and update the PR description to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/createFaceDetectionAgent.ts, line 30:

<comment>The PR description claims this change uses **Gemini 2.5 Flash**, but the code actually references `google/gemini-3.1-flash-lite-preview` — a model identifier that doesn't appear anywhere else in the codebase and contradicts the PR's own stated intent. If this model ID is fabricated or hallucinated, the agent will fail at runtime. Please verify the model identifier is valid for your AI gateway and update the PR description to match.</comment>

<file context>
@@ -27,7 +27,7 @@ Return hasFace: false for everything else.`;
 export function createFaceDetectionAgent() {
   return new ToolLoopAgent({
-    model: "google/gemini-2.5-flash",
+    model: "google/gemini-3.1-flash-lite-preview",
     instructions,
     output: Output.object({ schema: faceDetectionSchema }),
</file context>
Suggested change
model: "google/gemini-3.1-flash-lite-preview",
model: "google/gemini-2.5-flash",
Fix with Cubic

instructions,
output: Output.object({ schema: faceDetectionSchema }),
stopWhen: stepCountIs(1),
});
}
104 changes: 37 additions & 67 deletions src/content/__tests__/detectFace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

const mockFalSubscribe = vi.fn();
vi.mock("../falSubscribe", () => ({
falSubscribe: (...args: unknown[]) => mockFalSubscribe(...args),
const mockGenerate = vi.fn();
vi.mock("../../agents/createFaceDetectionAgent", () => ({
createFaceDetectionAgent: () => ({
generate: mockGenerate,
}),
}));
Comment on lines +7 to 12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Vitest, are vi.mock()factories hoisted such that they cannot safely reference a top-levelconstfrom the same module withoutvi.hoisted()?

💡 Result:

Yes. In Vitest, vi.mock() calls are hoisted to the top of the file (executed before imports), and Vitest explicitly warns that this means the mock factory cannot use variables defined outside the factory—including a top-level const from the same module—because it may not be initialized yet (TDZ / ReferenceError). If you need to share local values with the factory, define them via vi.hoisted() (or use vi.doMock(), which is not hoisted but only affects subsequent imports). [1] [2]

Sources:
[1] Vitest API docs – vi.mock hoisting + “cannot use any variables inside the factory… defined outside the factory” + vi.hoisted guidance: (vitest.dev)
[2] Vitest guide – vi.mock is moved to the top of the file by transformation: (vitest.dev)

Citations:


Wrap mock dependencies in vi.hoisted() to ensure they initialize before the mock factory runs.

vi.mock() is hoisted during module transformation, so its factory executes before top-level const declarations. When the factory tries to reference mockGenerate, it hasn't been initialized yet, causing a TDZ error. Use vi.hoisted() to hoist variable initialization alongside the mock setup.

Fix with `vi.hoisted()`
-const mockGenerate = vi.fn();
+const { mockGenerate } = vi.hoisted(() => ({
+  mockGenerate: vi.fn(),
+}));
 vi.mock("../../agents/createFaceDetectionAgent", () => ({
   createFaceDetectionAgent: () => ({
     generate: mockGenerate,
   }),
 }));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mockGenerate = vi.fn();
vi.mock("../../agents/createFaceDetectionAgent", () => ({
createFaceDetectionAgent: () => ({
generate: mockGenerate,
}),
}));
const { mockGenerate } = vi.hoisted(() => ({
mockGenerate: vi.fn(),
}));
vi.mock("../../agents/createFaceDetectionAgent", () => ({
createFaceDetectionAgent: () => ({
generate: mockGenerate,
}),
}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/__tests__/detectFace.test.ts` around lines 7 - 12, The test's
mock references a top-level const (mockGenerate) from the vi.mock factory which
runs before that const is initialized; wrap the mockGenerate initialization with
vi.hoisted so it is hoisted alongside the mock factory. Specifically, replace
the top-level mockGenerate declaration used by the vi.mock for
createFaceDetectionAgent and initialize it via vi.hoisted(() => vi.fn()),
leaving the vi.mock factory returning an object whose createFaceDetectionAgent
-> generate uses that hoisted mockGenerate.


import { detectFace } from "../detectFace";
Expand All @@ -16,96 +18,56 @@ describe("detectFace", () => {
vi.clearAllMocks();
});

it("returns true when a person label is detected", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[10, 20, 100, 200]],
labels: ["person"],
},
},
});
it("returns true when the agent detects a face guide", async () => {
mockGenerate.mockResolvedValue({ output: { hasFace: true } });

const result = await detectFace("https://example.com/headshot.png");

expect(result).toBe(true);
expect(mockFalSubscribe).toHaveBeenCalledWith(
"fal-ai/florence-2-large/object-detection",
{ image_url: "https://example.com/headshot.png" },
);
});

it("returns true when a face label is detected among other objects", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 50, 50], [10, 20, 100, 200]],
labels: ["chair", "human face"],
},
},
});

const result = await detectFace("https://example.com/photo.png");

expect(result).toBe(true);
});

it("returns false when no person or face labels are detected", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 300, 300]],
labels: ["album cover"],
},
},
});
it("returns false when the agent detects no face guide", async () => {
mockGenerate.mockResolvedValue({ output: { hasFace: false } });

const result = await detectFace("https://example.com/album-cover.png");

expect(result).toBe(false);
});

it("returns false when results are empty", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [],
labels: [],
},
},
});
it("sends a few-shot example with the face guide reference image", async () => {
mockGenerate.mockResolvedValue({ output: { hasFace: true } });

const result = await detectFace("https://example.com/blank.png");
await detectFace("https://example.com/photo.png");

expect(result).toBe(false);
});
const callArgs = mockGenerate.mock.calls[0][0];
const messages = callArgs.messages;

it("returns false when detection fails", async () => {
mockFalSubscribe.mockRejectedValue(new Error("Detection failed"));
// First message: example face guide image URL + question
expect(messages[0].role).toBe("user");
const exampleImagePart = messages[0].content.find((p: { type: string }) => p.type === "image");
expect(exampleImagePart).toBeDefined();
expect(exampleImagePart.image).toContain("face-guide-example.png");

const result = await detectFace("https://example.com/broken.png");
// Second message: assistant answer for the example
expect(messages[1].role).toBe("assistant");

expect(result).toBe(false);
// Third message: actual image to classify
expect(messages[2].role).toBe("user");
const targetImagePart = messages[2].content.find((p: { type: string }) => p.type === "image");
expect(targetImagePart.image).toBe("https://example.com/photo.png");
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Add a toBeDefined() assertion on targetImagePart before accessing .image, matching the pattern used for exampleImagePart above. Without it, a missing image part yields an opaque TypeError instead of a clear test failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/content/__tests__/detectFace.test.ts, line 60:

<comment>Add a `toBeDefined()` assertion on `targetImagePart` before accessing `.image`, matching the pattern used for `exampleImagePart` above. Without it, a missing image part yields an opaque `TypeError` instead of a clear test failure.</comment>

<file context>
@@ -18,33 +22,42 @@ describe("detectFace", () => {
+    // Third message: actual image to classify
+    expect(messages[2].role).toBe("user");
+    const targetImagePart = messages[2].content.find((p: { type: string }) => p.type === "image");
+    expect(targetImagePart.image).toBe("https://example.com/photo.png");
   });
 
</file context>
Suggested change
expect(targetImagePart.image).toBe("https://example.com/photo.png");
expect(targetImagePart).toBeDefined();
expect(targetImagePart.image).toBe("https://example.com/photo.png");
Fix with Cubic

});

it("does not false-positive on labels containing face words as substrings", async () => {
mockFalSubscribe.mockResolvedValue({
data: {
results: {
bboxes: [[0, 0, 200, 200]],
labels: ["ottoman", "mannequin", "womanizer"],
},
},
});
it("returns false when the agent throws", async () => {
mockGenerate.mockRejectedValue(new Error("Model error"));

const result = await detectFace("https://example.com/furniture.png");
const result = await detectFace("https://example.com/broken.png");

expect(result).toBe(false);
});

it("logs the error when detection fails", async () => {
const { logStep } = await import("../../sandboxes/logStep");
mockFalSubscribe.mockRejectedValue(new Error("Rate limit exceeded"));
mockGenerate.mockRejectedValue(new Error("Rate limit exceeded"));

await detectFace("https://example.com/broken.png");

Expand All @@ -115,4 +77,12 @@ describe("detectFace", () => {
expect.objectContaining({ error: "Rate limit exceeded" }),
);
});

it("returns false when output is null", async () => {
mockGenerate.mockResolvedValue({ output: null });

const result = await detectFace("https://example.com/broken.png");

expect(result).toBe(false);
});
});
53 changes: 33 additions & 20 deletions src/content/detectFace.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,47 @@
import { logStep } from "../sandboxes/logStep";
import { falSubscribe } from "./falSubscribe";
import { createFaceDetectionAgent } from "../agents/createFaceDetectionAgent";

const DETECTION_MODEL = "fal-ai/florence-2-large/object-detection";

/** Labels that indicate a human face or person is present in the image. */
const FACE_LABELS = ["person", "face", "human face", "man", "woman", "boy", "girl"];
const FACE_GUIDE_EXAMPLE_URL =
"https://dxfamqbi5zyezrs5.public.blob.vercel-storage.com/content-attachments/image/1775671967694-face-guide-example.png";

/**
* Detects whether an image contains a human face using Florence-2 object detection.
* Detects whether an image is a face guide (headshot/portrait on a plain background)
* rather than a playlist cover, album art, or other image that may incidentally contain a face.
*
* Uses a few-shot approach: shows the model an example face guide first, then asks
* it to classify the target image.
*
* @param imageUrl - URL of the image to analyze
* @returns true if at least one face/person is detected, false otherwise
* @returns true if the image is a face guide, false otherwise
*/
export async function detectFace(imageUrl: string): Promise<boolean> {
try {
const result = await falSubscribe(DETECTION_MODEL, {
image_url: imageUrl,
const agent = createFaceDetectionAgent();
const { output } = await agent.generate({
messages: [
{
role: "user",
content: [
{ type: "image", image: FACE_GUIDE_EXAMPLE_URL },
{ type: "text", text: "This is an example of a face guide — a headshot or portrait on a plain/white background used for face-swapping. Is this a face guide?" },
],
},
{
role: "assistant",
content: [{ type: "text", text: '{"hasFace":true}' }],
},
{
role: "user",
content: [
{ type: "image", image: imageUrl },
{ type: "text", text: "Is this image a face guide like the example above? A face guide is a headshot or portrait on a plain background. Playlist covers, album art, promotional graphics, and other images that happen to show a face are NOT face guides." },
],
},
],
});

const data = result.data as Record<string, unknown>;
const results = data.results as { labels?: string[] } | undefined;
const labels = results?.labels ?? [];

const hasFace = labels.some((label) => {
const lower = label.toLowerCase();
return FACE_LABELS.some(
(faceLabel) => lower === faceLabel || lower.split(" ").includes(faceLabel),
);
});
logStep("Face detection result", false, { imageUrl: imageUrl.slice(0, 80), hasFace, labels });
const hasFace = output?.hasFace ?? false;
logStep("Face detection result", false, { imageUrl: imageUrl.slice(0, 80), hasFace });
Comment on lines +43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redact the image URL before logging it.

Line 21 still logs a raw slice of the source URL. Signed asset URLs often put bearer tokens or query params at the front, and even when they don’t this creates a high-cardinality log field. Log a stable surrogate instead, and reuse it in the failure path too.

🛡️ Safer log payload
 export async function detectFace(imageUrl: string): Promise<boolean> {
+  let imageRefForLog = "[invalid-url]";
   try {
     const agent = createFaceDetectionAgent();
+    const parsedImageUrl = new URL(imageUrl);
+    imageRefForLog = `${parsedImageUrl.origin}${parsedImageUrl.pathname}`;
     const { output } = await agent.generate({
       prompt: [
-        { type: "image", image: new URL(imageUrl) },
+        { type: "image", image: parsedImageUrl },
         { type: "text", text: "Does this image contain a human face as the primary subject?" },
       ],
     });
 
     const hasFace = output?.hasFace ?? false;
-    logStep("Face detection result", false, { imageUrl: imageUrl.slice(0, 80), hasFace });
+    logStep("Face detection result", false, { imageUrl: imageRefForLog, hasFace });
     return hasFace;
   } catch (err) {
     logStep("Face detection failed, assuming no face", false, {
-      imageUrl: imageUrl.slice(0, 80),
+      imageUrl: imageRefForLog,
       error: err instanceof Error ? err.message : String(err),
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/detectFace.ts` around lines 20 - 21, The code logs a raw slice of
imageUrl in logStep; replace that with a redacted stable surrogate (e.g.,
compute a short deterministic hash or strip query/auth params into a safe id)
and use that surrogate variable instead of imageUrl.slice(...); update the calls
around hasFace and any failure path in detectFace.ts (referencing imageUrl,
hasFace, and logStep) so no raw URL is logged anywhere and the same surrogate is
reused for all related log entries.

return hasFace;
} catch (err) {
logStep("Face detection failed, assuming no face", false, {
Expand Down
Loading