Skip to content
Open
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
25 changes: 25 additions & 0 deletions packages/integrations/pi/extensions/model-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Model capability checks, used to keep tool results within what the active
* model can actually consume.
*
* pi's model catalogue declares what each model accepts through `input`.
* `screenshot` returns real image content, so a model whose `input` has no
* `"image"` entry cannot use it: the capture is dropped or ignored, and the turn
* is spent for nothing. pi does not raise an error in that case, so the tool has
* to check for itself.
*
* Unknown shapes return `undefined` and callers should fail open rather than
* block a model they cannot classify.
*/

export type ModelCapabilities = { readonly input?: unknown } | undefined;

/**
* `true` when the model accepts image input, `false` when it only accepts text,
* `undefined` when the model or its `input` list is unknown.
*/
export function modelAcceptsImages(model: ModelCapabilities): boolean | undefined {
const input = model?.input;
if (!Array.isArray(input)) return undefined;
return input.includes("image");
}
34 changes: 31 additions & 3 deletions packages/integrations/pi/extensions/stagehand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
* importing the contract (descriptions, runtime validators, system prompt)
* from @browserbasehq/stagehand-integrations/facade rather than restating it.
*/
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type {
AgentToolResult,
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

import {
Expand All @@ -27,6 +31,16 @@ import {
stagehandFacadeConfigFromEnv,
} from "@browserbasehq/stagehand-integrations/facade";

import { modelAcceptsImages } from "./model-capabilities.js";

// `screenshot` returns real image content, which only vision-capable models can
// consume. Models advertise that through `input` ("text" | "image"). When the
// active model has no image input, the capture is dropped or ignored and pi
// reports no error, so the turn is spent for nothing.
function modelAcceptsImagesForContext(ctx: ExtensionContext | undefined): boolean | undefined {
return modelAcceptsImages(ctx?.model);
}

type FacadeResources = {
browser: StagehandBrowser;
stagehand: Stagehand;
Expand Down Expand Up @@ -149,10 +163,24 @@ export default function stagehandExtension(pi: ExtensionAPI) {
name: "screenshot",
label: "Stagehand screenshot",
description: SCREENSHOT_TOOL_DESCRIPTION,
promptSnippet: "screenshot: capture the rendered page as an image",
promptSnippet:
"screenshot: capture the rendered page as an image (needs a vision-capable model)",
parameters: screenshotParameters,
executionMode: "sequential",
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const acceptsImages = modelAcceptsImagesForContext(ctx);
if (acceptsImages === false) {
const active = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "the active model";
return {
content: [
{
type: "text",
text: `screenshot is unavailable: ${active} does not accept image input, so a capture would be silently discarded. Use the snapshot tool or a run call that returns only the values you need. If visual inspection is required, ask the user to switch to a vision-capable model.`,
},
],
details: { skipped: "model-has-no-image-input" },
} satisfies AgentToolResult<unknown>;
}
const input = ScreenshotInputSchema.parse(params);
const tools = await facadeTools();
const shot = await tools.screenshot(input);
Expand Down
33 changes: 32 additions & 1 deletion packages/integrations/pi/tests/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@ import { describe, expect, it } from "vitest";

import stagehandExtension from "../extensions/stagehand.js";

type Registered = { name: string; description: string; promptGuidelines?: string[] };
type Registered = {
name: string;
description: string;
promptGuidelines?: string[];
promptSnippet?: string;
execute?: (
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate: unknown,
ctx: unknown,
) => Promise<{ content: Array<{ type: string; text?: string }>; details?: unknown }>;
};

function registeredTools(): Registered[] {
const tools: Registered[] = [];
Expand Down Expand Up @@ -36,6 +48,25 @@ describe("pi stagehand extension", () => {
expect(run?.promptGuidelines).toEqual([FACADE_AGENT_INSTRUCTIONS]);
});

it("advertises the vision requirement on screenshot", () => {
const screenshot = registeredTools().find((tool) => tool.name === "screenshot");
expect(screenshot?.promptSnippet).toContain("vision");
});

it("refuses screenshot for a text-only model instead of returning an image", async () => {
const screenshot = registeredTools().find((tool) => tool.name === "screenshot");
const result = await screenshot?.execute?.("call-1", {}, undefined, undefined, {
model: { provider: "opencode-go", id: "qwen3.7-max", input: ["text"] },
});
expect(result?.details).toEqual({ skipped: "model-has-no-image-input" });
expect(result?.content).toEqual([
{
type: "text",
text: expect.stringContaining("opencode-go/qwen3.7-max does not accept image input"),
},
]);
});

it("does not launch a browser at registration time", () => {
// Registration with no credentials must not throw or open anything.
expect(() => registeredTools()).not.toThrow();
Expand Down
22 changes: 22 additions & 0 deletions packages/integrations/pi/tests/model-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";

import { modelAcceptsImages } from "../extensions/model-capabilities.js";

describe("modelAcceptsImages", () => {
it("accepts models that declare image input", () => {
expect(modelAcceptsImages({ input: ["text", "image"] })).toBe(true);
expect(modelAcceptsImages({ input: ["image"] })).toBe(true);
});

it("rejects text-only models", () => {
expect(modelAcceptsImages({ input: ["text"] })).toBe(false);
expect(modelAcceptsImages({ input: [] })).toBe(false);
});

it("returns undefined when the capability is unknown, so callers fail open", () => {
expect(modelAcceptsImages(undefined)).toBeUndefined();
expect(modelAcceptsImages({})).toBeUndefined();
expect(modelAcceptsImages({ input: "image" })).toBeUndefined();
expect(modelAcceptsImages({ input: null })).toBeUndefined();
});
});
Loading