summaryrefslogtreecommitdiffhomepage
path: root/packages/vision-handoff/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 03:40:38 +0900
committerAdam Malczewski <[email protected]>2026-06-27 03:40:38 +0900
commitd5633cf6e007eaf8255a44529a638d2466a74ba3 (patch)
tree14fe72f5b585eb72c763073b4e7022b914bdbafb /packages/vision-handoff/src
parentad9d135e583c99a0d93327115defa43187cde1c3 (diff)
downloaddispatch-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.ts106
-rw-r--r--packages/vision-handoff/src/index.ts19
-rw-r--r--packages/vision-handoff/src/pure.test.ts141
-rw-r--r--packages/vision-handoff/src/pure.ts129
-rw-r--r--packages/vision-handoff/src/service.test.ts242
-rw-r--r--packages/vision-handoff/src/service.ts281
-rw-r--r--packages/vision-handoff/src/tool.ts68
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,
+ };
+ }
+ },
+ };
+}