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/README.md +155 -48
- package/package.json +4 -4
- package/src/dataloader.ts +208 -0
- package/src/describer.ts +320 -0
- package/src/dispose.ts +61 -0
- package/src/image.ts +274 -0
- package/src/index.ts +133 -7
- package/src/usage.ts +17 -1
- package/vision-handoff.ts +284 -229
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
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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;
|