- Replaced legacy `pi/` role alias prefix with canonical `@` syntax across model resolution, documentation, and tests. - Added support for bare `*` default alias and multiple alias prefix detection with custom role resolution in `resolveConfiguredRolePattern()`. - Enhanced thinking suffix parsing to accept unambiguous abbreviations (minimum 2 characters) for effort and level selectors. - Extended `resolveCliModel()` and `filterAvailableModelsByEnabledPatterns()` to accept settings parameter for role alias resolution from `--model` flag.
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
import { type Api, completeSimple, type ImageContent, type Model, type ToolExample } from "@oh-my-pi/pi-ai";
|
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
import { type } from "arktype";
|
|
import { extractTextContent } from "../commit/utils";
|
|
|
|
import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver";
|
|
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
|
|
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
|
|
import {
|
|
ImageInputTooLargeError,
|
|
type LoadedImageInput,
|
|
loadImageAttachmentInput,
|
|
loadImageInput,
|
|
MAX_IMAGE_INPUT_BYTES,
|
|
webpExclusionForModel,
|
|
} from "../utils/image-loading";
|
|
import type { ToolSession } from "./index";
|
|
import { ToolError } from "./tool-errors";
|
|
|
|
const inspectImageSchema = type({
|
|
path: type("string").describe("image file path, Image #N label, or attachment://N URI"),
|
|
question: type("string").describe("question about image"),
|
|
"+": "reject",
|
|
});
|
|
|
|
export type InspectImageParams = typeof inspectImageSchema.infer;
|
|
|
|
interface ImageAttachmentReference {
|
|
index: number;
|
|
}
|
|
|
|
const IMAGE_ATTACHMENT_REFERENCE_REGEX =
|
|
/^\s*(?:\[?Image #([1-9]\d*)(?:,[^\]\n]*)?\]?|(?:attachment|image):\/\/([1-9]\d*))\s*$/i;
|
|
|
|
function parseImageAttachmentReference(path: string): ImageAttachmentReference | null {
|
|
const match = IMAGE_ATTACHMENT_REFERENCE_REGEX.exec(path);
|
|
if (!match) return null;
|
|
const rawIndex = match[1] ?? match[2];
|
|
if (!rawIndex) return null;
|
|
return { index: Number(rawIndex) };
|
|
}
|
|
|
|
function formatAvailableImageAttachments(attachments: readonly { label: string; uri: string }[]): string {
|
|
if (attachments.length === 0) return "none";
|
|
return attachments.map(attachment => `${attachment.label} -> ${attachment.uri}`).join(", ");
|
|
}
|
|
|
|
async function loadAttachmentReferenceInput(options: {
|
|
path: string;
|
|
reference: ImageAttachmentReference;
|
|
attachments: readonly { label: string; uri: string; image: ImageContent }[];
|
|
autoResize: boolean;
|
|
excludeWebP: boolean | undefined;
|
|
}): Promise<LoadedImageInput | null> {
|
|
const attachment = options.attachments[options.reference.index - 1];
|
|
if (!attachment) {
|
|
const available = formatAvailableImageAttachments(options.attachments);
|
|
if (options.attachments.length === 0) {
|
|
throw new ToolError(
|
|
`No image attachments are available in this turn. path="${options.path}" must be a readable file path or attachment URI.`,
|
|
);
|
|
}
|
|
throw new ToolError(
|
|
`Could not resolve image attachment '${options.path}'. Available image attachments: ${available}. Pass an attachment URI or a readable filesystem path.`,
|
|
);
|
|
}
|
|
return loadImageAttachmentInput({
|
|
image: attachment.image,
|
|
label: attachment.label,
|
|
uri: attachment.uri,
|
|
autoResize: options.autoResize,
|
|
maxBytes: MAX_IMAGE_INPUT_BYTES,
|
|
excludeWebP: options.excludeWebP,
|
|
});
|
|
}
|
|
|
|
export interface InspectImageToolDetails {
|
|
model: string;
|
|
imagePath: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
|
|
readonly name = "inspect_image";
|
|
readonly approval = "read" as const;
|
|
readonly label = "InspectImage";
|
|
readonly loadMode = "discoverable";
|
|
readonly summary = "Describe or analyze an image file";
|
|
readonly description: string;
|
|
readonly parameters = inspectImageSchema;
|
|
readonly strict = false;
|
|
|
|
readonly examples: readonly ToolExample<typeof inspectImageSchema.infer>[] = [
|
|
{
|
|
caption: "OCR with strict formatting",
|
|
call: {
|
|
path: "screenshots/error.png",
|
|
question: "Extract all visible text verbatim. Return as bullet list in reading order.",
|
|
},
|
|
},
|
|
{
|
|
caption: "Screenshot debugging",
|
|
call: {
|
|
path: "screenshots/settings.png",
|
|
question:
|
|
"Identify the likely cause of the disabled Save button. Return: (1) observations, (2) likely cause, (3) confidence.",
|
|
},
|
|
},
|
|
{
|
|
caption: "Scene/object question",
|
|
call: {
|
|
path: "photos/shelf.jpg",
|
|
question:
|
|
"List all clearly visible product labels and their shelf positions (top/middle/bottom). If unreadable, say unreadable.",
|
|
},
|
|
},
|
|
];
|
|
|
|
constructor(
|
|
private readonly session: ToolSession,
|
|
private readonly completeImageRequest: typeof completeSimple = completeSimple,
|
|
) {
|
|
this.description = prompt.render(inspectImageDescription);
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: InspectImageParams,
|
|
signal?: AbortSignal,
|
|
_onUpdate?: AgentToolUpdateCallback<InspectImageToolDetails>,
|
|
_context?: AgentToolContext,
|
|
): Promise<AgentToolResult<InspectImageToolDetails>> {
|
|
if (this.session.settings.get("images.blockImages")) {
|
|
throw new ToolError(
|
|
"Image submission is disabled by settings (images.blockImages=true). Disable it to use inspect_image.",
|
|
);
|
|
}
|
|
|
|
const modelRegistry = this.session.modelRegistry;
|
|
if (!modelRegistry) {
|
|
throw new ToolError("Model registry is unavailable for inspect_image.");
|
|
}
|
|
|
|
const availableModels = modelRegistry.getAvailable();
|
|
if (availableModels.length === 0) {
|
|
throw new ToolError("No models available for inspect_image.");
|
|
}
|
|
|
|
const matchPreferences = getModelMatchPreferences(this.session.settings);
|
|
const resolvePattern = (pattern: string | undefined): Model<Api> | undefined => {
|
|
if (!pattern) return undefined;
|
|
const expanded = expandRoleAlias(pattern, this.session.settings);
|
|
return resolveModelFromString(expanded, availableModels, matchPreferences);
|
|
};
|
|
|
|
const activeModelPattern = this.session.getActiveModelString?.() ?? this.session.getModelString?.();
|
|
const model =
|
|
resolvePattern("@vision") ??
|
|
resolvePattern("@default") ??
|
|
resolvePattern(activeModelPattern) ??
|
|
availableModels[0];
|
|
if (!model) {
|
|
throw new ToolError("Unable to resolve a model for inspect_image.");
|
|
}
|
|
|
|
if (!model.input.includes("image")) {
|
|
throw new ToolError(
|
|
`Resolved model ${model.provider}/${model.id} does not support image input. Configure a vision-capable model for modelRoles.vision.`,
|
|
);
|
|
}
|
|
|
|
const apiKey = await modelRegistry.getApiKey(model);
|
|
if (!apiKey) {
|
|
throw new ToolError(
|
|
`No API key available for ${model.provider}/${model.id}. Configure credentials for this provider or choose another vision-capable model.`,
|
|
);
|
|
}
|
|
|
|
let imageInput: LoadedImageInput | null;
|
|
const autoResize = this.session.settings.get("images.autoResize");
|
|
const excludeWebP = webpExclusionForModel(model);
|
|
const attachmentReference = parseImageAttachmentReference(params.path);
|
|
try {
|
|
if (attachmentReference) {
|
|
imageInput = await loadAttachmentReferenceInput({
|
|
path: params.path,
|
|
reference: attachmentReference,
|
|
attachments: this.session.getImageAttachments?.() ?? [],
|
|
autoResize,
|
|
excludeWebP,
|
|
});
|
|
} else {
|
|
imageInput = await loadImageInput({
|
|
path: params.path,
|
|
cwd: this.session.cwd,
|
|
autoResize,
|
|
maxBytes: MAX_IMAGE_INPUT_BYTES,
|
|
excludeWebP,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ImageInputTooLargeError) {
|
|
throw new ToolError(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!imageInput) {
|
|
throw new ToolError("inspect_image only supports PNG, JPEG, GIF, and WEBP files detected by file content.");
|
|
}
|
|
|
|
const telemetry = resolveTelemetry(this.session.getTelemetry?.(), this.session.getSessionId?.() ?? undefined);
|
|
const response = await instrumentedCompleteSimple(
|
|
model,
|
|
{
|
|
systemPrompt: [prompt.render(inspectImageSystemPromptTemplate)],
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
|
|
{ type: "text", text: params.question },
|
|
],
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
apiKey: modelRegistry.resolver(model, this.session.getSessionId?.() ?? undefined),
|
|
signal,
|
|
},
|
|
{ telemetry, oneshotKind: "inspect_image", completeImpl: this.completeImageRequest },
|
|
);
|
|
|
|
if (response.stopReason === "error") {
|
|
throw new ToolError(response.errorMessage ?? "inspect_image request failed.");
|
|
}
|
|
if (response.stopReason === "aborted") {
|
|
throw new ToolError("inspect_image request aborted.");
|
|
}
|
|
|
|
const text = extractTextContent(response);
|
|
if (!text) {
|
|
throw new ToolError("inspect_image model returned no text output.");
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
model: `${model.provider}/${model.id}`,
|
|
imagePath: imageInput.resolvedPath,
|
|
mimeType: imageInput.mimeType,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export { inspectImageToolRenderer } from "./inspect-image-renderer";
|