diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 03:40:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 03:40:38 +0900 |
| commit | d5633cf6e007eaf8255a44529a638d2466a74ba3 (patch) | |
| tree | 14fe72f5b585eb72c763073b4e7022b914bdbafb /packages/vision-handoff/src | |
| parent | ad9d135e583c99a0d93327115defa43187cde1c3 (diff) | |
| download | dispatch-d5633cf6e007eaf8255a44529a638d2466a74ba3.tar.gz dispatch-d5633cf6e007eaf8255a44529a638d2466a74ba3.zip | |
feat(vision-handoff): implement vision for capable models and universal vision handoff
Diffstat (limited to 'packages/vision-handoff/src')
| -rw-r--r-- | packages/vision-handoff/src/extension.ts | 106 | ||||
| -rw-r--r-- | packages/vision-handoff/src/index.ts | 19 | ||||
| -rw-r--r-- | packages/vision-handoff/src/pure.test.ts | 141 | ||||
| -rw-r--r-- | packages/vision-handoff/src/pure.ts | 129 | ||||
| -rw-r--r-- | packages/vision-handoff/src/service.test.ts | 242 | ||||
| -rw-r--r-- | packages/vision-handoff/src/service.ts | 281 | ||||
| -rw-r--r-- | packages/vision-handoff/src/tool.ts | 68 |
7 files changed, 986 insertions, 0 deletions
diff --git a/packages/vision-handoff/src/extension.ts b/packages/vision-handoff/src/extension.ts new file mode 100644 index 0000000..aa745b7 --- /dev/null +++ b/packages/vision-handoff/src/extension.ts @@ -0,0 +1,106 @@ +/** + * vision-handoff extension — registers the universal vision handoff service + + * the `read_image` tool. + * + * The service performs provider-agnostic vision handoff: it resolves a + * vision-capable model from the catalog (any provider), streams an image to it + * via the standard `ProviderContract.stream` interface, and folds the textual + * description back — so a non-vision model (e.g. glm-5.2) can still reason about + * images, and any model can analyze image FILES referenced in code. + * + * Effects (filesystem, fetch) live here in the shell, injected into the service. + * The pure decisions live in `pure.ts`. No `console.*`; logging via `host.logger`. + */ + +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, resolve as pathResolve } from "node:path"; +import type { CredentialStore } from "@dispatch/credential-store"; +import { credentialStoreHandle } from "@dispatch/credential-store"; +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import { createVisionHandoffService, visionHandoffHandle } from "./service.js"; +import { createReadImageTool } from "./tool.js"; + +export const manifest: Manifest = { + id: "vision-handoff", + name: "Vision Handoff", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { services: ["vision-handoff/service"], tools: ["read_image"] }, +}; + +/** MIME types for recognized image extensions. */ +const MIME_BY_EXT: Readonly<Record<string, string>> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", +}; + +/** + * Read an image file from disk as a base64 data URL. Resolves relative paths + * against the cwd (the conversation's working directory). Throws on missing + * file / read error (the caller surfaces it). The shell edge — real `node:fs`. + */ +async function readFileAsDataUrl(path: string, cwd?: string): Promise<string> { + const abs = cwd !== undefined && !isAbsolute(path) ? pathResolve(cwd, path) : pathResolve(path); + const buf = await readFile(abs); + const ext = extname(abs).toLowerCase(); + const mime = MIME_BY_EXT[ext] ?? "image/png"; + return `data:${mime};base64,${buf.toString("base64")}`; +} + +/** + * Fetch an HTTP(S) image URL and convert it to a base64 data URL (so it can be + * sent to the vision model inline, regardless of whether the provider can fetch + * remote URLs). The shell edge — real `globalThis.fetch`. + */ +async function fetchUrlAsDataUrl(url: string): Promise<string> { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch image: HTTP ${res.status}`); + } + const buf = new Uint8Array(await res.arrayBuffer()); + const mime = res.headers.get("content-type") ?? "image/png"; + // Buffer/base64 in Bun + Node. Convert byte-by-byte without non-null asserts. + let binary = ""; + for (const byte of buf) binary += String.fromCharCode(byte); + const base64 = btoa(binary); + return `data:${mime};base64,${base64}`; +} + +export async function activate(host: HostAPI): Promise<void> { + const credentialStore = host.getService(credentialStoreHandle) as CredentialStore | undefined; + if (credentialStore === undefined) { + host.logger.warn( + "vision-handoff: credential-store service not available. The read_image tool and image transcription are disabled.", + ); + return; + } + + const resolveModel = (modelName: string) => { + const resolved = credentialStore.resolve(modelName); + if (resolved === undefined) return undefined; + const provider = host.getProviders().get(resolved.providerId); + if (provider === undefined) return undefined; + return { provider, model: resolved.model }; + }; + + const service = createVisionHandoffService({ + credentialStore, + resolveModel, + readFileAsDataUrl, + fetchUrlAsDataUrl, + logger: host.logger.child({ extensionId: "vision-handoff" }), + }); + + host.provideService(visionHandoffHandle, service); + host.defineTool(createReadImageTool(service)); + host.logger.info("vision-handoff: registered (read_image tool + transcription service)"); +} + +export const extension: Extension = { manifest, activate }; diff --git a/packages/vision-handoff/src/index.ts b/packages/vision-handoff/src/index.ts new file mode 100644 index 0000000..4a13e65 --- /dev/null +++ b/packages/vision-handoff/src/index.ts @@ -0,0 +1,19 @@ +export { extension, manifest } from "./extension.js"; +export { + buildTranscriptionPrompt, + collectTextFromStream, + findVisionModelName, + formatNoVisionPlaceholder, + formatTranscriptionText, + isVisionCapable, +} from "./pure.js"; +export type { + ResolvedVisionModel, + VisionHandoffDeps, + VisionHandoffService, +} from "./service.js"; +export { + createVisionHandoffService, + visionHandoffHandle, +} from "./service.js"; +export { createReadImageTool } from "./tool.js"; diff --git a/packages/vision-handoff/src/pure.test.ts b/packages/vision-handoff/src/pure.test.ts new file mode 100644 index 0000000..89dac72 --- /dev/null +++ b/packages/vision-handoff/src/pure.test.ts @@ -0,0 +1,141 @@ +import type { ModelInfo, ProviderEvent } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { + buildTranscriptionPrompt, + collectTextFromStream, + findVisionModelName, + formatNoVisionPlaceholder, + formatTranscriptionText, + isVisionCapable, +} from "./pure.js"; + +describe("isVisionCapable", () => { + it("returns true when ModelInfo.vision is true", () => { + expect(isVisionCapable("umans/kimi-k2.7", { id: "kimi-k2.7", vision: true })).toBe(true); + }); + + it("returns false when ModelInfo.vision is false (overrides name heuristic)", () => { + expect(isVisionCapable("umans/kimi-k2.7", { id: "kimi-k2.7", vision: false })).toBe(false); + }); + + it("falls back to name heuristic when vision is absent (kimi)", () => { + expect(isVisionCapable("umans/kimi-k2.7", undefined)).toBe(true); + expect(isVisionCapable("umans/Kimi-K2.7", undefined)).toBe(true); // case-insensitive + }); + + it("falls back to name heuristic when vision is absent (non-kimi)", () => { + expect(isVisionCapable("umans/glm-5.2", undefined)).toBe(false); + expect(isVisionCapable("umans/deepseek-v4-flash", { id: "deepseek-v4-flash" })).toBe(false); + }); + + it("returns false for undefined model name", () => { + expect(isVisionCapable(undefined, undefined)).toBe(false); + }); +}); + +describe("findVisionModelName", () => { + const getInfo = async (name: string): Promise<ModelInfo | undefined> => { + const map: Record<string, ModelInfo> = { + "umans/kimi-k2.7": { id: "kimi-k2.7", vision: true }, + "umans/glm-5.2": { id: "glm-5.2" }, + "umans/llama-vision": { id: "llama-vision", vision: true }, + }; + return map[name]; + }; + + it("finds the first kimi-family model via name heuristic (no async lookup needed)", async () => { + const name = await findVisionModelName( + ["umans/glm-5.2", "umans/kimi-k2.7", "umans/llama-vision"], + getInfo, + ); + expect(name).toBe("umans/kimi-k2.7"); + }); + + it("finds a vision model via ModelInfo.vision when name heuristic misses", async () => { + const name = await findVisionModelName(["umans/glm-5.2", "umans/llama-vision"], getInfo); + expect(name).toBe("umans/llama-vision"); + }); + + it("skips the excluded model", async () => { + const name = await findVisionModelName( + ["umans/kimi-k2.7", "umans/llama-vision"], + getInfo, + "umans/kimi-k2.7", + ); + expect(name).toBe("umans/llama-vision"); + }); + + it("returns undefined when no vision model is available", async () => { + const name = await findVisionModelName(["umans/glm-5.2"], getInfo); + expect(name).toBeUndefined(); + }); + + it("returns undefined for empty catalog", async () => { + const name = await findVisionModelName([], getInfo); + expect(name).toBeUndefined(); + }); +}); + +describe("collectTextFromStream", () => { + async function* stream(events: ProviderEvent[]): AsyncIterable<ProviderEvent> { + for (const e of events) yield e; + } + + it("collects text-delta events into a single string", async () => { + const events: ProviderEvent[] = [ + { type: "text-delta", delta: "Hello " }, + { type: "text-delta", delta: "world!" }, + ]; + const text = await collectTextFromStream(stream(events)); + expect(text).toBe("Hello world!"); + }); + + it("ignores non-text events (reasoning, usage, tool-call, finish)", async () => { + const events: ProviderEvent[] = [ + { type: "reasoning-delta", delta: "thinking..." }, + { type: "text-delta", delta: "answer" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 1 } }, + { type: "finish", reason: "stop" }, + ]; + const text = await collectTextFromStream(stream(events)); + expect(text).toBe("answer"); + }); + + it("throws on an error event", async () => { + const events: ProviderEvent[] = [ + { type: "text-delta", delta: "partial" }, + { type: "error", message: "boom" }, + ]; + await expect(collectTextFromStream(stream(events))).rejects.toThrow("boom"); + }); + + it("returns empty string for an empty stream", async () => { + const text = await collectTextFromStream(stream([])); + expect(text).toBe(""); + }); +}); + +describe("prompt + formatting helpers", () => { + it("buildTranscriptionPrompt includes focus when a question is given", () => { + const prompt = buildTranscriptionPrompt("What error is shown?"); + expect(prompt).toContain("Describe this image in detail"); + expect(prompt).toContain('The user asked: "What error is shown?"'); + }); + + it("buildTranscriptionPrompt omits focus when no question", () => { + const prompt = buildTranscriptionPrompt(undefined); + expect(prompt).toContain("Describe this image in detail"); + expect(prompt).not.toContain("The user asked"); + }); + + it("formatTranscriptionText names the vision model", () => { + expect(formatTranscriptionText("a red car", "umans/kimi-k2.7")).toBe( + "[Image analysis (via umans/kimi-k2.7)]: a red car", + ); + }); + + it("formatNoVisionPlaceholder explains the limitation", () => { + const text = formatNoVisionPlaceholder(); + expect(text).toContain("no vision-capable model"); + }); +}); diff --git a/packages/vision-handoff/src/pure.ts b/packages/vision-handoff/src/pure.ts new file mode 100644 index 0000000..11eeefc --- /dev/null +++ b/packages/vision-handoff/src/pure.ts @@ -0,0 +1,129 @@ +/** + * Pure decision helpers for the vision handoff. + * + * No I/O, no ambient state. The shell (the extension + the service) injects the + * effects (credential store lookups, provider streaming). This module owns only + * the policy: which model is vision-capable, how to build a transcription + * request, and how to fold a provider's streamed text into a description. + */ + +import type { ModelInfo, ProviderEvent } from "@dispatch/kernel"; +import { isVisionModelId } from "@dispatch/openai-stream"; + +/** + * Whether a model is vision-capable, given its catalog name and (optional) + * resolved `ModelInfo`. When `ModelInfo.vision` is present it is authoritative; + * otherwise fall back to the hardcoded name heuristic ({@link isVisionModelId}). + * + * The `modelName` is the `<credentialName>/<model>` catalog form; the heuristic + * inspects the model SEGMENT (after the first `/`) so `umans/kimi-k2.7` → the + * `kimi-k2.7` segment is checked. Pure. + */ +export function isVisionCapable( + modelName: string | undefined, + info: ModelInfo | undefined, +): boolean { + // When ModelInfo explicitly reports vision (true OR false), it is authoritative + // — an explicit false overrides the name heuristic (a provider that KNOWS a + // model is non-vision wins over the name guess). + if (info?.vision !== undefined) return info.vision; + if (modelName === undefined) return false; + const slash = modelName.indexOf("/"); + const modelId = slash >= 0 ? modelName.slice(slash + 1) : modelName; + return isVisionModelId(modelId); +} + +/** + * Find the first vision-capable model name in a catalog, given a lookup that + * resolves a `<credentialName>/<model>` → `ModelInfo`. Returns `undefined` when + * no vision-capable model is available (the handoff degrades: images are + * replaced with a placeholder note). Pure given the (async) lookup — no + * ambient state, no side effects. + * + * @param catalog The full list of model names (`<credentialName>/<model>`). + * @param getInfo Async lookup of a model name → ModelInfo (from the credential store). + * @param exclude Optional model name to skip (e.g. the current non-vision model). + */ +export async function findVisionModelName( + catalog: readonly string[], + getInfo: (modelName: string) => Promise<ModelInfo | undefined>, + exclude?: string, +): Promise<string | undefined> { + for (const name of catalog) { + if (exclude !== undefined && name === exclude) continue; + // Fast path: the name heuristic lets us short-circuit without an async + // lookup for known vision families (kimi). This avoids a round-trip to + // listModels for the common case. + const slash = name.indexOf("/"); + const modelId = slash >= 0 ? name.slice(slash + 1) : name; + if (isVisionModelId(modelId)) return name; + const info = await getInfo(name); + if (info?.vision === true) return name; + } + return undefined; +} + +/** + * Fold a provider's streamed events into a single text string (the + * transcription). Pure given the async iterable — collects `text-delta` events, + * ignores everything else (reasoning, usage, tool-calls, errors). If the stream + * yields an error event, it is surfaced as a thrown Error so the caller can + * decide how to degrade (placeholder vs. fail). Pure: input → output, no I/O. + */ +export async function collectTextFromStream(stream: AsyncIterable<ProviderEvent>): Promise<string> { + let text = ""; + for await (const event of stream) { + if (event.type === "text-delta") { + text += event.delta; + } else if (event.type === "error") { + throw new Error(event.message); + } + } + return text; +} + +/** + * Build the prompt sent to the vision model to transcribe an image. Kept here + * (pure) so the prompt is testable and stable. The prompt asks for a thorough + * description so the text-only model has enough detail to reason about the + * image's contents. Pure. + * + * @param userQuestion The user's own message text (may be empty) — passed so + * the vision model can tailor its description to what the user actually asked. + */ +export function buildTranscriptionPrompt(userQuestion: string | undefined): string { + const focus = + userQuestion && userQuestion.trim().length > 0 + ? `\n\nThe user asked: "${userQuestion.trim()}". Focus your description on what is relevant to that question, but still describe the whole image.` + : ""; + return ( + "Describe this image in detail. Include: the overall scene/subject, " + + "visible text (transcribe verbatim), key objects, layout, colors, and any " + + "notable details a developer or user would need to understand the image." + + focus + ); +} + +/** + * Format a single image's transcription as a text chunk string for the + * persisted user message. The note names the vision model so the consumer knows + * the description's provenance. Pure. + */ +export function formatTranscriptionText( + description: string, + visionModelName: string | undefined, +): string { + const source = visionModelName ?? "vision model"; + return `[Image analysis (via ${source})]: ${description}`; +} + +/** + * Placeholder text used when NO vision-capable model is available (the + * degraded path). Pure. + */ +export function formatNoVisionPlaceholder(): string { + return ( + "[Image attached — no vision-capable model is available to analyze it. " + + "Install or configure a vision-capable model (e.g. kimi) to enable image analysis.]" + ); +} diff --git a/packages/vision-handoff/src/service.test.ts b/packages/vision-handoff/src/service.test.ts new file mode 100644 index 0000000..fe99d17 --- /dev/null +++ b/packages/vision-handoff/src/service.test.ts @@ -0,0 +1,242 @@ +import type { + ChatMessage, + ModelInfo, + ProviderContract, + ProviderEvent, + ProviderStreamOptions, + ToolContract, +} from "@dispatch/kernel"; +import { describe, expect, it, vi } from "vitest"; +import { createVisionHandoffService, type VisionHandoffDeps } from "./service.js"; + +// ── Test doubles (outermost-edge fakes — NOT @dispatch/* mocks) ────────────── + +function makeVisionProvider( + describe: (imageUrl: string) => string, + id = "umans", +): ProviderContract { + return { + id, + stream: vi.fn( + ( + messages: readonly ChatMessage[], + _tools: readonly ToolContract[], + _opts?: ProviderStreamOptions, + ): AsyncIterable<ProviderEvent> => { + const img = messages.flatMap((m) => m.chunks).find((c) => c.type === "image"); + const url = img && img.type === "image" ? img.url : ""; + const text = describe(url); + async function* gen(): AsyncIterable<ProviderEvent> { + yield { type: "text-delta", delta: text }; + yield { type: "finish", reason: "stop" }; + } + return gen(); + }, + ), + }; +} + +function makeDeps(overrides: Partial<VisionHandoffDeps> = {}): VisionHandoffDeps { + const visionProvider = makeVisionProvider((url) => `DESCRIPTION of ${url}`); + const catalog = ["umans/kimi-k2.7", "umans/glm-5.2"]; + const infoMap: Record<string, ModelInfo> = { + "umans/kimi-k2.7": { id: "kimi-k2.7", vision: true }, + "umans/glm-5.2": { id: "glm-5.2" }, + }; + return { + credentialStore: { + listCatalog: vi.fn(async () => catalog), + getModelInfo: vi.fn(async (name: string) => infoMap[name]), + resolve: vi.fn((name: string) => { + if (name === "umans/kimi-k2.7") return { providerId: "umans", model: "kimi-k2.7" }; + if (name === "umans/glm-5.2") return { providerId: "umans", model: "glm-5.2" }; + return undefined; + }), + }, + resolveModel: vi.fn((name: string) => + name === "umans/kimi-k2.7" || name === "umans/glm-5.2" + ? { provider: visionProvider, model: name.split("/")[1] } + : undefined, + ), + readFileAsDataUrl: vi.fn(async (path: string) => `data:image/png;base64,FILE(${path})`), + ...overrides, + }; +} + +describe("VisionHandoffService.isVisionCapable", () => { + it("returns true for kimi (via ModelInfo)", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable("umans/kimi-k2.7")).toBe(true); + }); + + it("returns false for glm-5.2", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable("umans/glm-5.2")).toBe(false); + }); + + it("returns false for undefined model name", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable(undefined)).toBe(false); + }); +}); + +describe("VisionHandoffService.resolveVisionModel", () => { + it("resolves the kimi model from the catalog", async () => { + const svc = createVisionHandoffService(makeDeps()); + const vision = await svc.resolveVisionModel(); + expect(vision?.modelName).toBe("umans/kimi-k2.7"); + expect(vision?.model).toBe("kimi-k2.7"); + }); + + it("excludes the given model", async () => { + const svc = createVisionHandoffService(makeDeps()); + const vision = await svc.resolveVisionModel("umans/kimi-k2.7"); + // kimi is the only vision model; excluding it → undefined. + expect(vision).toBeUndefined(); + }); +}); + +describe("VisionHandoffService.transcribeImage", () => { + it("returns a formatted description from the vision model", async () => { + const svc = createVisionHandoffService(makeDeps()); + const result = await svc.transcribeImage("data:image/png;base64,xxx", "what is this?"); + expect(result).toBe( + "[Image analysis (via umans/kimi-k2.7)]: DESCRIPTION of data:image/png;base64,xxx", + ); + }); + + it("returns a placeholder when no vision model is available", async () => { + const deps = makeDeps(); + // Empty catalog → no vision model. + (deps.credentialStore.listCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]); + const svc = createVisionHandoffService(deps); + const result = await svc.transcribeImage("data:image/png;base64,xxx", undefined); + expect(result).toContain("no vision-capable model"); + }); + + it("returns an error note when the vision stream errors", async () => { + const errorProvider: ProviderContract = { + id: "umans", + stream: vi.fn(async function* (): AsyncIterable<ProviderEvent> { + yield { type: "error", message: "vision API down" }; + }), + }; + const deps = makeDeps({ + resolveModel: vi.fn(() => ({ provider: errorProvider, model: "kimi-k2.7" })), + }); + const svc = createVisionHandoffService(deps); + const result = await svc.transcribeImage("data:image/png;base64,xxx", undefined); + expect(result).toContain("Image analysis failed: vision API down"); + }); +}); + +describe("VisionHandoffService.transcribeForProvider", () => { + it("passes messages through unchanged when the model is vision-capable", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "What's this?" }, + { type: "image", url: "data:image/png;base64,abc" }, + ], + }, + ]; + const result = await svc.transcribeForProvider(messages, "umans/kimi-k2.7"); + expect(result).toBe(messages); // same reference — no copy, no transcription + }); + + it("passes messages through unchanged when there are no images", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]; + const result = await svc.transcribeForProvider(messages, "umans/glm-5.2"); + expect(result).toBe(messages); + }); + + it("transcribes image chunks to text for a non-vision model", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Describe this" }, + { type: "image", url: "data:image/png;base64,img1" }, + ], + }, + ]; + const result = await svc.transcribeForProvider(messages, "umans/glm-5.2"); + expect(result).toHaveLength(1); + const chunks = result[0]?.chunks; + expect(chunks).toHaveLength(2); + expect(chunks?.[0]).toEqual({ type: "text", text: "Describe this" }); + // The image chunk was replaced with a transcribed text chunk. + expect(chunks?.[1]?.type).toBe("text"); + expect((chunks?.[1] as { text: string }).text).toContain("Image analysis"); + expect((chunks?.[1] as { text: string }).text).toContain("img1"); + }); + + it("caches transcription per unique image URL within a call", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "image", url: "data:image/png;base64,same" }, + { type: "image", url: "data:image/png;base64,same" }, + ], + }, + ]; + const result = await svc.transcribeForProvider(messages, "umans/glm-5.2"); + const chunks = result[0]?.chunks; + // Both image chunks → text, same description (cached). + expect(chunks).toHaveLength(2); + expect((chunks?.[0] as { text: string }).text).toBe((chunks?.[1] as { text: string }).text); + // The vision provider was called only once (cache hit on the second). + const provider = deps.resolveModel("umans/kimi-k2.7")?.provider; + expect((provider?.stream as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1); + }); + + it("transcribes images in history messages too (non-vision model)", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,hist" }] }, + { role: "assistant", chunks: [{ type: "text", text: "got it" }] }, + { role: "user", chunks: [{ type: "text", text: "and now?" }] }, + ]; + const result = await svc.transcribeForProvider(messages, "umans/glm-5.2"); + // First message's image chunk is now text. + expect(result[0]?.chunks[0]?.type).toBe("text"); + expect((result[0]?.chunks[0] as { text: string }).text).toContain("Image analysis"); + // Assistant message unchanged. + expect(result[1]?.chunks[0]?.type).toBe("text"); + // Last user message unchanged. + expect(result[2]?.chunks[0]).toEqual({ type: "text", text: "and now?" }); + }); + + it("uses a placeholder when no vision model is available (non-vision model)", async () => { + const deps = makeDeps(); + (deps.credentialStore.listCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,abc" }] }, + ]; + const result = await svc.transcribeForProvider(messages, "umans/glm-5.2"); + expect((result[0]?.chunks[0] as { text: string }).text).toContain("no vision-capable model"); + }); +}); + +describe("VisionHandoffService.readImageFile", () => { + it("reads the file and transcribes it", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const result = await svc.readImageFile("screenshot.png", "/work"); + expect(deps.readFileAsDataUrl).toHaveBeenCalledWith("screenshot.png", "/work"); + expect(result).toContain("Image analysis"); + expect(result).toContain("FILE(screenshot.png)"); + }); +}); diff --git a/packages/vision-handoff/src/service.ts b/packages/vision-handoff/src/service.ts new file mode 100644 index 0000000..5e6ad70 --- /dev/null +++ b/packages/vision-handoff/src/service.ts @@ -0,0 +1,281 @@ +/** + * Vision handoff service — the imperative shell that performs the universal, + * provider-agnostic vision handoff. + * + * Two capabilities: + * 1. **Transcription for non-vision models** (`transcribeForProvider`): when a + * user message carries images but the active model cannot see them, this + * calls a vision-capable model (resolved from the catalog — any provider) to + * describe each image, then replaces the image chunks with text. Universal: + * it uses the standard `ProviderContract.stream` interface, never a + * provider-specific vision endpoint. + * 2. **`read_image` tool** (`readImageFile`): reads an image FILE from disk and + * transcribes it via a vision-capable model, returning the text description + * — so any model (vision or not) can analyze an image referenced in code. + * + * Effects (credential store, provider streaming, filesystem, fetch) are + * injected. The pure decisions live in `pure.ts`. This shell wires them. + */ + +import type { CredentialStore } from "@dispatch/credential-store"; +import type { + ChatMessage, + Chunk, + Logger, + ModelInfo, + ProviderContract, + ProviderStreamOptions, +} from "@dispatch/kernel"; +import { defineService, type ServiceHandle } from "@dispatch/kernel"; +import { + buildTranscriptionPrompt, + collectTextFromStream, + findVisionModelName, + formatNoVisionPlaceholder, + formatTranscriptionText, + isVisionCapable, +} from "./pure.js"; + +/** + * Resolved vision model — a provider + its model id, ready to stream from. + */ +export interface ResolvedVisionModel { + readonly provider: ProviderContract; + readonly model: string; + readonly modelName: string; +} + +/** + * Dependencies the service needs — all injected (no ambient state). + */ +export interface VisionHandoffDeps { + readonly credentialStore: CredentialStore; + /** Resolve a `<credentialName>/<model>` → its provider + model id. */ + readonly resolveModel: ( + modelName: string, + ) => { provider: ProviderContract; model: string } | undefined; + /** + * Read a file from disk as a base64 data URL. Injected so the shell controls + * the filesystem edge (and tests inject a fake). Returns the data URL, or + * throws on error (the caller surfaces it as a tool error). + */ + readonly readFileAsDataUrl: (path: string, cwd?: string) => Promise<string>; + /** + * Fetch an HTTP(S) URL to a data URL (for http image sources). Injected so + * tests inject a fake. Optional — when absent, HTTP image URLs are passed to + * the vision provider as-is (it fetches them). + */ + readonly fetchUrlAsDataUrl?: (url: string) => Promise<string>; + readonly logger?: Logger; +} + +export interface VisionHandoffService { + /** + * Whether a given model (by catalog name) is vision-capable. Uses the + * credential store's ModelInfo + the name heuristic. Async because ModelInfo + * may require a listModels round-trip (cached by the credential store). + */ + readonly isVisionCapable: (modelName: string | undefined) => Promise<boolean>; + + /** + * Resolve a vision-capable model from the catalog (any provider). Returns + * `undefined` when none is available. + */ + readonly resolveVisionModel: (excludeName?: string) => Promise<ResolvedVisionModel | undefined>; + + /** + * Transcribe a single image URL to a text description via a vision-capable + * model. Returns the description, or a placeholder string when no vision + * model is available (does NOT throw — callers want graceful degradation). + */ + readonly transcribeImage: ( + imageUrl: string, + userQuestion: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ) => Promise<string>; + + /** + * Transform a message list for the provider: if the active model is + * vision-capable, return messages unchanged (images pass through natively). + * If NOT vision-capable, replace every `image` chunk with a text + * description (transcribed via a vision model — once per unique image URL, + * cached within the call) so a text-only model can still reason about the + * images. Never throws — on failure an image becomes a placeholder note. + * + * The PERSISTED history is NOT modified by this (the caller persists the + * original messages with images); this only transforms what the provider sees. + */ + readonly transcribeForProvider: ( + messages: readonly ChatMessage[], + currentModelName: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ) => Promise<readonly ChatMessage[]>; + + /** + * Read an image FILE from disk and transcribe it (the `read_image` tool's + * core). Returns the description text. Throws on filesystem error (the tool + * surfaces it as a tool-error result). + */ + readonly readImageFile: ( + path: string, + cwd: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ) => Promise<string>; +} + +export const visionHandoffHandle: ServiceHandle<VisionHandoffService> = + defineService<VisionHandoffService>("vision-handoff/service"); + +/** Whether a message list contains any image chunks. Pure. */ +function hasImageChunks(messages: readonly ChatMessage[]): boolean { + return messages.some((m) => m.chunks.some((c) => c.type === "image")); +} + +export function createVisionHandoffService(deps: VisionHandoffDeps): VisionHandoffService { + const log = deps.logger; + + async function getInfo(modelName: string): Promise<ModelInfo | undefined> { + return deps.credentialStore.getModelInfo(modelName); + } + + async function resolveVisionModel( + excludeName?: string, + ): Promise<ResolvedVisionModel | undefined> { + const catalog = await deps.credentialStore.listCatalog(); + const name = await findVisionModelName(catalog, getInfo, excludeName); + if (name === undefined) return undefined; + const resolved = deps.resolveModel(name); + if (resolved === undefined) return undefined; + return { provider: resolved.provider, model: resolved.model, modelName: name }; + } + + async function streamVisionText( + vision: ResolvedVisionModel, + imageUrl: string, + prompt: string, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ): Promise<string> { + // Build a single-turn user message: [text prompt, image]. The vision model + // receives the image natively via the OpenAI-compatible content array + // (convertMessages serializes the image chunk to image_url). + const userMessage: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: prompt }, + { type: "image", url: imageUrl }, + ], + }; + const providerOpts: ProviderStreamOptions = { + model: vision.model, + // Low temperature for faithful transcription. + temperature: 0, + // A short system prompt keeps the vision model focused on describing. + systemPrompt: + "You are a vision assistant. Describe images faithfully and thoroughly for a developer who cannot see them.", + }; + const streamOpts: Parameters<ProviderContract["stream"]>[2] = { + ...providerOpts, + ...(opts?.logger !== undefined ? { logger: opts.logger } : {}), + }; + const stream = vision.provider.stream([userMessage], [], streamOpts); + return collectTextFromStream(stream); + } + + const service: VisionHandoffService = { + async isVisionCapable(modelName: string | undefined): Promise<boolean> { + if (modelName === undefined) return false; + const info = await getInfo(modelName); + return isVisionCapable(modelName, info); + }, + + resolveVisionModel, + + async transcribeImage( + imageUrl: string, + userQuestion: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ): Promise<string> { + const vision = await resolveVisionModel(); + if (vision === undefined) { + log?.warn("vision-handoff: no vision-capable model available for transcription"); + return formatNoVisionPlaceholder(); + } + const prompt = buildTranscriptionPrompt(userQuestion); + try { + const description = await streamVisionText(vision, imageUrl, prompt, opts); + const trimmed = description.trim(); + if (trimmed.length === 0) { + return "[Image analysis produced no output.]"; + } + return formatTranscriptionText(trimmed, vision.modelName); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log?.warn("vision-handoff: transcription failed", { error: msg }); + return `[Image analysis failed: ${msg}]`; + } + }, + + async transcribeForProvider( + messages: readonly ChatMessage[], + currentModelName: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ): Promise<readonly ChatMessage[]> { + // Fast path: no images anywhere → nothing to do. + if (!hasImageChunks(messages)) return messages; + + // If the active model IS vision-capable, pass images through natively. + if (currentModelName !== undefined) { + const capable = await isVisionCapable(currentModelName, await getInfo(currentModelName)); + if (capable) return messages; + } + + // Non-vision model: transcribe each unique image URL once (cached). + const cache = new Map<string, string>(); + const userText = messages + .filter((m) => m.role === "user") + .flatMap((m) => m.chunks) + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(" "); + + async function transcribeCached(url: string): Promise<string> { + const cached = cache.get(url); + if (cached !== undefined) return cached; + const description = await service.transcribeImage(url, userText, opts); + cache.set(url, description); + return description; + } + + const result: ChatMessage[] = []; + for (const msg of messages) { + if (!msg.chunks.some((c) => c.type === "image")) { + result.push(msg); + continue; + } + // Replace image chunks with transcribed text chunks; keep all else. + const newChunks: Chunk[] = []; + for (const chunk of msg.chunks) { + if (chunk.type === "image") { + const description = await transcribeCached(chunk.url); + newChunks.push({ type: "text", text: description }); + } else { + newChunks.push(chunk); + } + } + result.push({ role: msg.role, chunks: newChunks }); + } + return result; + }, + + async readImageFile( + path: string, + cwd: string | undefined, + opts?: { readonly signal?: AbortSignal; readonly logger?: Logger }, + ): Promise<string> { + const dataUrl = await deps.readFileAsDataUrl(path, cwd); + return service.transcribeImage(dataUrl, undefined, opts); + }, + }; + + return service; +} diff --git a/packages/vision-handoff/src/tool.ts b/packages/vision-handoff/src/tool.ts new file mode 100644 index 0000000..3995598 --- /dev/null +++ b/packages/vision-handoff/src/tool.ts @@ -0,0 +1,68 @@ +/** + * read_image tool — lets any model (vision-capable or not) analyze an image + * FILE on disk by handing it off to a vision-capable model. + * + * The tool reads the image file into a base64 data URL, then asks the vision + * handoff service to transcribe it (via a vision-capable model resolved from + * the catalog) and returns the textual description as the tool result. This is + * the universal mechanism: it works regardless of whether the active model has + * vision, because the result is plain text the model reasons about. + * + * For images PASTED into the chat, the orchestrator's auto-transcription handles + * them (no tool call needed). This tool is for images REFERENCED IN CODE by path + * (e.g. a screenshot, diagram, or mockup the model discovered while reading files). + */ + +import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; +import type { VisionHandoffService } from "./service.js"; + +export function createReadImageTool(service: VisionHandoffService): ToolContract { + return { + name: "read_image", + description: + "Read and analyze an image file on disk (PNG, JPEG, WebP, GIF). Returns a " + + "detailed textual description of the image's contents — useful when you " + + "encounter a screenshot, diagram, UI mockup, or chart referenced in the " + + "codebase and need to understand what it shows. The analysis is performed " + + "by a vision-capable model, so you can use this even if you cannot " + + "directly view images. Pass a file path (relative to the cwd or absolute).", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Path to the image file to analyze. Relative paths resolve against " + + "the conversation's working directory; absolute paths are used as-is.", + }, + }, + required: ["path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const input = args as { path?: unknown } | null; + const path = input?.path; + if (typeof path !== "string" || path.trim().length === 0) { + return { + content: "Error: 'path' is required and must be a non-empty string.", + isError: true, + }; + } + const span = ctx.log.span("read_image.execute", { path }); + try { + const description = await service.readImageFile(path, ctx.cwd, { + signal: ctx.signal, + logger: ctx.log, + }); + span.end({ attrs: { descriptionLength: description.length } }); + return { content: description }; + } catch (err: unknown) { + span.end({ err }); + return { + content: `Error reading image: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; +} |
