pi-vision-handoff 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -38,14 +38,39 @@ export const DEFAULT_USER_PROMPT_PREFIX = "The user's request about this image:
38
38
  export const IMAGE_PLACEHOLDER_PREFIX = "[Image: ";
39
39
  export const IMAGE_PLACEHOLDER_SUFFIX = "]";
40
40
 
41
- /** Default max output tokens for a single image description. */
42
- export const DEFAULT_MAX_TOKENS = 1024;
41
+ /** Marker appended to a description whose `complete()` call ended with
42
+ * `stopReason: "length"` i.e. the vision model hit a token limit (either the
43
+ * configured `maxTokens` or the provider's hard output cap) before finishing.
44
+ * A truncated description is still useful, but the agent must not mistake it
45
+ * for the complete picture (it would reason as if the image contained only
46
+ * what made it into the text). The marker makes the cut-off visible to both
47
+ * the agent (it reads the marker in the tool result / context) and the user
48
+ * (it renders inline). */
49
+ export const DESCRIPTION_TRUNCATED_MARKER =
50
+ "\n\n[... description truncated — the vision model reached its output token limit. The above may be incomplete; raise maxTokens or use a higher-output vision model for the full description.]";
51
+
52
+ /** Append the {@link DESCRIPTION_TRUNCATED_MARKER} to a raw description. */
53
+ export function markDescriptionTruncated(text: string): string {
54
+ return text + DESCRIPTION_TRUNCATED_MARKER;
55
+ }
43
56
 
44
57
  /** Default vision cache size (number of described images kept in memory per session). */
45
58
  export const DEFAULT_CACHE_MAX = 50;
46
59
 
47
- /** Per-description request timeout. */
48
- export const DESCRIBE_TIMEOUT_MS = 30_000;
60
+ /** Per-description request timeout. Generous because the describer generates an
61
+ * exhaustive multi-paragraph description per image; a single image commonly
62
+ * takes ~20s, and a batched call over websocket transport scales with image
63
+ * count. {@link describeTimeoutMs} scales this per batch size. */
64
+ export const DESCRIBE_TIMEOUT_MS = 120_000;
65
+
66
+ /** Per-batch timeout: the base timeout plus a per-image budget so a 5-image
67
+ * batch isn't held to the same wall-clock budget as a single image. The
68
+ * describer generates exhaustive prose per image, and websocket transport
69
+ * adds latency, so the budget scales with the number of images in the call. */
70
+ export function describeTimeoutMs(imageCount: number): number {
71
+ const perImage = 45_000;
72
+ return DESCRIBE_TIMEOUT_MS + Math.max(0, imageCount - 1) * perImage;
73
+ }
49
74
 
50
75
  /**
51
76
  * Default cap on the number of lines kept from a description before truncation.
@@ -91,8 +116,13 @@ export interface VisionHandoffConfig {
91
116
  autoHandoff: boolean;
92
117
  /** Extra "provider/id" refs that should ALSO receive handoff (e.g. weak vision models). */
93
118
  handoffModels: string[];
94
- /** Max output tokens for a single description. */
95
- maxTokens: number;
119
+ /** Max output tokens for a single description. `undefined` (default) = use the
120
+ * vision model's declared max output (`model.maxTokens`) as the cap — the
121
+ * highest the model supports — so the "be exhaustive" prompt isn't defeated
122
+ * by a provider's small omitted-default. Set a number only to cap lower.
123
+ * A truncation is always surfaced via {@link DESCRIPTION_TRUNCATED_MARKER}
124
+ * when the model hits the limit. */
125
+ maxTokens?: number;
96
126
  /** Max images kept in the in-memory description cache. */
97
127
  cacheMax: number;
98
128
  /**
@@ -112,7 +142,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
112
142
  visionModel: null,
113
143
  autoHandoff: true,
114
144
  handoffModels: [],
115
- maxTokens: DEFAULT_MAX_TOKENS,
145
+ maxTokens: undefined,
116
146
  cacheMax: DEFAULT_CACHE_MAX,
117
147
  maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
118
148
  };
@@ -159,6 +189,8 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
159
189
  .map((m) => m.trim())
160
190
  .filter((m) => m && parseModelRef(m));
161
191
  }
192
+ // maxTokens: optional. undefined (default) = no artificial cap. Only set when
193
+ // a valid positive finite number is given; any other value leaves it unset.
162
194
  if (typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) && obj.maxTokens > 0) {
163
195
  base.maxTokens = Math.floor(obj.maxTokens);
164
196
  }
@@ -292,6 +324,100 @@ export function truncateDescription(text: string, maxLines: number): TruncatedDe
292
324
  return { text: `${kept}\n... (${hidden} more lines)`, truncated: true, hidden };
293
325
  }
294
326
 
327
+ /** Start marker for the k-th image section in a batched describer response. */
328
+ export const BATCH_IMAGE_MARKER_START = "<<<IMAGE";
329
+ /** End marker closing a single image section in a batched describer response. */
330
+ export const BATCH_IMAGE_MARKER_END = "<<<END>>>";
331
+
332
+ /** Escape a literal string for use in a RegExp. */
333
+ function escapeRe(s: string): string {
334
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
335
+ }
336
+
337
+ // Section regexes built from the marker constants so the producer
338
+ // (batchUserPrompt) and consumer (parseBatchedDescriptions) share one source of
339
+ // truth for the delimiter format. A lone `<<<IMAGE k>>> … <<<END>>>` wrapper
340
+ // for a single image; a global scan for the multi-image case that stops at the
341
+ // next start marker, the end marker, or end-of-text.
342
+ const SECTION_SINGLE_RE = new RegExp(
343
+ `${escapeRe(BATCH_IMAGE_MARKER_START)}\\s+1>>>\\s*([\\s\\S]*?)${escapeRe(BATCH_IMAGE_MARKER_END)}`,
344
+ );
345
+ const SECTION_MULTI_RE = new RegExp(
346
+ `${escapeRe(BATCH_IMAGE_MARKER_START)}\\s+(\\d+)>>>\\s*([\\s\\S]*?)(?=${escapeRe(
347
+ BATCH_IMAGE_MARKER_START,
348
+ )}\\s+\\d+>>>|${escapeRe(BATCH_IMAGE_MARKER_END)}|$)`,
349
+ "g",
350
+ );
351
+ const SECTION_END_STRIP_RE = new RegExp(`${escapeRe(BATCH_IMAGE_MARKER_END)}\\s*$`);
352
+
353
+ /** Build the user-message text block for a (possibly batched) describer call.
354
+ *
355
+ * For a single image (count <= 1) this returns the same simple prompt the
356
+ * original per-image pipeline used (no markers), so single-image behaviour is
357
+ * unchanged. For count > 1 it instructs the vision model to emit one delimited
358
+ * `<<<IMAGE k>>> … <<<END>>>` section per image, in order, so the response can
359
+ * be split back into per-image descriptions by {@link parseBatchedDescriptions}.
360
+ *
361
+ * The user's turn prompt (if any) is folded in via {@link DEFAULT_USER_PROMPT_PREFIX}
362
+ * so every image in the batch is described in the context of the same request —
363
+ * one describer call covers the whole prompt's image set, dataloader-style,
364
+ * rather than spinning up one agent call per image. */
365
+ export function batchUserPrompt(count: number, userPrompt: string, prefix: string): string {
366
+ const userLine = userPrompt && userPrompt.trim() ? `${prefix}${userPrompt}` : "";
367
+ if (count <= 1) {
368
+ return userLine || "Describe this image.";
369
+ }
370
+ return [
371
+ `Describe each of the ${count} images below exhaustively, in order. For image k (1 through ${count}), emit exactly:`,
372
+ "",
373
+ `${BATCH_IMAGE_MARKER_START} k>>>`,
374
+ "<your exhaustive description>",
375
+ BATCH_IMAGE_MARKER_END,
376
+ ...(userLine ? ["", userLine] : []),
377
+ ].join("\n");
378
+ }
379
+
380
+ /** Parse a batched vision-model response into one description per image, in order.
381
+ *
382
+ * Returns an array of length `count`; each slot is the trimmed description for
383
+ * image k (1-indexed → array index k-1), or `null` when that image's section is
384
+ * missing, empty, or unparseable. Callers treat `null` as "description
385
+ * unavailable" (graceful degradation) — one image failing to parse must not
386
+ * void the rest of the batch.
387
+ *
388
+ * For `count <= 1` the whole response is treated as the single image's
389
+ * description (a lone `<<<IMAGE 1>>> … <<<END>>>` wrapper is stripped if the
390
+ * model emitted one anyway), preserving the original single-image behaviour. */
391
+ export function parseBatchedDescriptions(text: string, count: number): (string | null)[] {
392
+ const trimmed = (text ?? "").trim();
393
+ if (count <= 1) {
394
+ const m = SECTION_SINGLE_RE.exec(trimmed);
395
+ const body = (m ? m[1] : trimmed).trim();
396
+ return [body || null];
397
+ }
398
+ const out: (string | null)[] = new Array(count).fill(null);
399
+ let m: RegExpExecArray | null;
400
+ SECTION_MULTI_RE.lastIndex = 0;
401
+ while ((m = SECTION_MULTI_RE.exec(trimmed)) !== null) {
402
+ const idx = parseInt(m[1], 10) - 1;
403
+ const body = m[2].replace(SECTION_END_STRIP_RE, "").trim();
404
+ if (idx >= 0 && idx < count && !out[idx]) out[idx] = body || null;
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /** Truncate (if configured) and wrap a raw description in the `[Image: …]`
410
+ * placeholder envelope used by every insertion path. Centralises the wrap+
411
+ * truncate logic shared by the read-tool and context injection paths so both
412
+ * surfaces stay identical for the same image hash. */
413
+ export function wrapDescription(description: string, cfg: VisionHandoffConfig): string {
414
+ const { text: final } =
415
+ cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
416
+ ? truncateDescription(description, cfg.maxDescriptionLines)
417
+ : { text: description };
418
+ return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
419
+ }
420
+
295
421
  /** Outcome of {@link insertImageDescriptions}. */
296
422
  export interface ReplacedContent {
297
423
  content: (TextContent | ImageContent)[];
package/src/usage.ts CHANGED
@@ -49,9 +49,19 @@ export const EMPTY_ENERGY_CAPTURE: VisionHandoffEnergyCapture = {
49
49
  };
50
50
 
51
51
  /** A single describer-call usage record. Energy fields are present only when
52
- * Neuralwatt SSE energy comments were captured (omitted, not zeroed, otherwise). */
52
+ * Neuralwatt SSE energy comments were captured (omitted, not zeroed, otherwise).
53
+ *
54
+ * One record is emitted per REAL describer provider call (cache hits emit
55
+ * nothing). A batched call that describes several images at once still emits a
56
+ * single record: `imageHash` is the representative (first) member and
57
+ * `imageHashes` lists every image the call covered, so consumers can attribute
58
+ * the call's tokens/energy to each member image without double-counting. */
53
59
  export interface VisionHandoffUsageRecord {
60
+ /** Representative image hash (first member of the batch). */
54
61
  imageHash: string;
62
+ /** All image hashes covered by this describer call. Present only for batched
63
+ * calls (length > 1); omitted for single-image calls. */
64
+ imageHashes?: string[];
55
65
  model: string;
56
66
  provider: string;
57
67
  responseModel?: string;
@@ -148,6 +158,7 @@ export function buildUsageRecord(
148
158
  capture: VisionHandoffEnergyCapture,
149
159
  visionModel: Model<Api>,
150
160
  imageHash: string,
161
+ imageHashes?: string[],
151
162
  ): VisionHandoffUsageRecord | null {
152
163
  const hasEnergy = !!(
153
164
  capture.energyRaw ||
@@ -169,6 +180,11 @@ export function buildUsageRecord(
169
180
  responseId: response.responseId,
170
181
  usage: response.usage,
171
182
  };
183
+ // Present only for genuine batched calls (more than one image). Keeps the
184
+ // single-image record shape unchanged for existing consumers.
185
+ if (imageHashes && imageHashes.length > 1) {
186
+ record.imageHashes = imageHashes;
187
+ }
172
188
  if (hasEnergy) {
173
189
  record.energyJoules = capture.energyJoules;
174
190
  record.costUsd = capture.costUsd;