summaryrefslogtreecommitdiffhomepage
path: root/packages/vision-handoff/src/pure.ts
blob: af3476fe465e08e408d84a3ffaf76a8ff5c99d11 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/**
 * 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, orchestrator, provider streaming). This
 * module owns only the policy: which model is vision-capable, how to format
 * image placeholders for non-vision models, and how to format the
 * consultation tool's result.
 */

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. Pure given the (async) lookup.
 *
 * @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).
    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. Pure given the
 * async iterable — collects `text-delta` events, ignores everything else
 * (reasoning, usage, tool-calls). If the stream yields an error event, it is
 * surfaced as a thrown Error so the caller can decide how to degrade.
 */
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;
}

/**
 * Format the placeholder text that replaces an `image` chunk when a non-vision
 * model is active. The placeholder tells the model an image is attached and it
 * should call `consult_vision` to analyze it — the model drives the analysis
 * (asking a specific question) rather than receiving a pre-emptive generic dump.
 *
 * @param imageId  The 1-based ID assigned to this image (used by the tool to
 *   look up the registered image data).
 * Pure.
 */
export function formatImagePlaceholder(imageId: number): string {
  return (
    `[Image ${imageId} attached — you cannot view images. Call the ` +
    `consult_vision tool with imageIds=[${imageId}] and a specific question ` +
    `to analyze it via a vision-capable model.]`
  );
}

/**
 * Placeholder text used when NO vision-capable model is available (the
 * degraded path — the tool cannot function). 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.]"
  );
}

/**
 * Maximum length of the consultation title body (matching the conversation
 * store's `TITLE_MAX`). The question is truncated to this before the
 * `"IMAGE - "` prefix is applied so the consultation tab's title stays in line
 * with the store's own title-derivation limit.
 */
const CONSULTATION_TITLE_MAX = 80;

/**
 * Format the title for a vision consultation conversation tab. The title is
 * `"IMAGE - "` prefixed to the (truncated) question so the tab is visually
 * distinguishable from normal conversation tabs. The question is truncated to
 * match the conversation store's title-derivation limit (`TITLE_MAX = 80`).
 *
 * Pure.
 *
 * @param question  The question the model asked the vision model.
 */
export function formatConsultationTitle(question: string): string {
  const body =
    question.length > CONSULTATION_TITLE_MAX
      ? `${question.slice(0, CONSULTATION_TITLE_MAX)}…`
      : question;
  return `IMAGE - ${body}`;
}

/**
 * Format the `consult_vision` tool's result string. Returns the conversation ID
 * (so the model / user can continue the vision consultation), the vision model's
 * response, and a note that follow-up questions use the dispatch CLI (the model
 * can load the `dispatch-cli` skill for the exact commands).
 *
 * Pure.
 *
 * @param conversationId  The new vision consultation conversation ID.
 * @param response        The vision model's answer to the model's question.
 */
export function formatConsultResult(conversationId: string, response: string): string {
  const trimmed = response.trim();
  return (
    `Vision consultation opened in conversation ${conversationId}.\n\n` +
    `Response: ${trimmed}\n\n` +
    `To ask follow-up questions about this image, use the dispatch CLI ` +
    `(conversation: ${conversationId}).`
  );
}