pi-vision-handoff 0.3.1 → 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 CHANGED
@@ -26,20 +26,21 @@ The `pi-umans-provider` extension quietly solved this for GLM 5.1: a hardcoded "
26
26
 
27
27
  - **Pick any vision-capable model** from your registry via an interactive picker — OpenAI, Anthropic, Google, Ollama, or any custom provider pi knows about.
28
28
  - Your choice **persists** to `~/.pi/agent/extensions/pi-vision-handoff.json`.
29
- - For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and **inserts the description before the kept image** in the stored `read`-tool result **before** pi-ai can strip the image for non-vision models. The image stays for kitty rendering; pi-ai later strips only the image block, leaving the description text for the model.
29
+ - For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and swaps the image block for its description text at the **`context`** eventwhich fires *before* pi-ai's `downgradeUnsupportedImages` can strip image blocks for non-vision models. This covers every image source: pasted/attached images, `read`-tool results, and custom extension-injected messages. (Read-tool images additionally keep the description + image in the stored `tool_result` for kitty inline rendering and `/resume`.)
30
30
  - Works across all four image-block shapes pi uses — the three provider-transformed formats (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages) plus pi-ai's internal `{ type: "image", data, mimeType }` emitted by the `read` tool — detected by shape.
31
- - Descriptions are **cached per image hash** (LRU), so the swap is instant by the time the request fires.
31
+ - Descriptions are **cached per image hash** (LRU) and produced **one batched vision call per image set** (dataloader-style: a prompt describing N images does not spin up N describer calls), so the swap is instant by the time the request fires.
32
32
 
33
33
  No `settings.json` touched. No per-provider glue. Pick a describer once and every text-only model you own can suddenly see.
34
34
 
35
35
  ## Features
36
36
 
37
37
  - **🎮 Interactive picker** — `/vision-handoff` opens a TUI listing every model, vision-capable ones first (👁), to choose your describer.
38
- - **🖼️ Read-tool hijack** — `read`-tool images are intercepted at `tool_result` time, before pi-ai can strip them for non-vision models. The kept image still renders inline (kitty); the description is inserted as a text block the text-only model consumes.
38
+ - **🖼️ DataLoader-batched descriptions** — the `read` tools are `load()` callers: N parallel reads coalesce into ONE batched vision call (dispatched after the microtask cascade settles), awaited during the tool-result phase (free time) so the agent's next turn never blocks on the describer. Descriptions are ready before `context` fires, so the swap is a non-blocking cache hit.
39
+ - **🧹 Hides pi's "model does not support images" note** — on read results the extension strips pi's `[Current model does not support images…]` note from the text block (it's misleading once the handoff delivers the image's content as text), while keeping the image block for kitty inline rendering and `/resume`.
39
40
  - **🔌 Provider-agnostic** — uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `complete()`), so it works with any provider/configured model, including custom provider extensions.
40
41
  - **🧠 Automatic targets** — by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
41
42
  - **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
42
- - **⚡ Cache-warmed** — `before_agent_start` describes attached images the moment you submit, so the request is rarely delayed.
43
+ - **⚡ Pre-warmed at paste-enter** — the moment you press enter, `before_agent_start` scans the prompt for pasted clipboard image temp-file paths (pi writes pasted images to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text), reads them, and kicks off the ONE batched vision call concurrent with the agent's first response — so by the time the agent reads the files, they're already cache hits.
43
44
  - **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
44
45
  - **📊 Usage reporting** — every real describer call reports model + tokens (and Neuralwatt energy/cost when the vision model is a Neuralwatt model), via `pi.appendEntry` + a `vision-handoff:usage` event for live consumers.
45
46
  - **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
@@ -71,7 +72,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
71
72
  "visionModel": "openai/gpt-4o",
72
73
  "autoHandoff": true,
73
74
  "handoffModels": ["ollama/llava:13b"],
74
- "maxTokens": 1024,
75
+ "maxTokens": null,
75
76
  "cacheMax": 50,
76
77
  "maxDescriptionLines": 0,
77
78
  "prompt": "Describe this image exhaustively…",
@@ -85,7 +86,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
85
86
  | `visionModel` | `null` | The describer, as `provider/id`. `null` = not configured (handoff inactive). |
86
87
  | `autoHandoff` | `true` | Apply handoff to every model whose `input` does not include `image`. |
87
88
  | `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
88
- | `maxTokens` | `1024` | Cap on a single description's output. |
89
+ | `maxTokens` | _(unset = model max, clamped to context window)_ | Cap on a single description's output. `null`/unset = use the vision model's declared max output (`model.maxTokens`), clamped so `maxTokens + 8192 <= contextWindow` (a model whose declared max equals its full context window would otherwise be rejected with a 400). Set a number only to cap cost/latency. A truncation is always surfaced via a `[... description truncated …]` marker when the model hits the limit, so a cut-off description is never mistaken for complete. |
89
90
  | `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
90
91
  | `maxDescriptionLines` | `0` | Cap on description lines (`0` = unbounded). Default keeps the full description so the `read` tool's native collapse (`ctrl+o`) handles compactness and the model gets complete context; setting `> 0` applies a lossy head-cap to both the TUI render and the model. |
91
92
  | `prompt` | _(built-in)_ | Override the describer system prompt. |
@@ -133,49 +134,142 @@ pi -e ./vision-handoff.ts
133
134
 
134
135
  ## How It Works
135
136
 
136
- Read-tool images (the common case paste a screenshot, `read` an image file):
137
-
138
- read tool returns an image block
139
- tool_result (toolName === "read")
140
- • fires BEFORE pi-ai's transformMessages can strip the image for
141
- non-vision models (the strip would leave only a "(tool image
142
- omitted)" placeholder — too late to describe)
143
- • is this model a handoff target? (non-vision, or in handoffModels)
144
- • for each {type:"image",...} block → describeImage() with your
145
- chosen vision model
146
- - complete() via pi-ai (resolves API key/headers/baseUrl)
147
- - cached by sha256(mime + base64)
148
- • AUGMENTS the stored result: inserts "[Image: <description>]" as a
149
- text block BEFORE the kept image, returns {content} to pi
150
- • the image stays in content → kitty renders it inline
151
- • at provider time, pi-ai strips ONLY the image block for non-vision
152
- models — the inserted description text passes through to the model
153
-
154
- User-attached images (pasted into the prompt, not read via a tool):
137
+ The extension implements the **Facebook DataLoader pattern** for image
138
+ descriptions. The `read` tools are the `load()` callers; a per-image cache
139
+ memoizes promises; all `load()` calls in the same execution frame coalesce
140
+ into ONE batched vision call, dispatched after the microtask cascade settles.
155
141
 
156
142
  → before_agent_start
157
- warms the description cache for attached images (fire-and-forget)
158
- before_provider_request
159
- walks the provider payload, swaps image blocks for "[Image: <desc>]"
160
- NOTE: for non-vision models, pi-ai strips image blocks BEFORE this
161
- hook fires so user-attached images on text-only models are not yet
162
- described. Use the `read` tool on the image file instead, which routes
163
- through the tool_result path above.
164
-
165
- Result: the text-only model receives a vivid text description alongside the
166
- (now-stripped) image reference, and your turn proceeds normally — while the
167
- terminal still renders the image inline via kitty.
143
+ captures this turn's user prompt (shared by every image description)
144
+ binds turn context (vision model + abort signal) to the loader
145
+ PRE-WARMS at paste-enter via the loader, two sources coalescing into
146
+ ONE batch:
147
+ 1. attached image blocks (event.images) vision-capable targets
148
+ 2. pasted clipboard image FILE PATHS in the prompt text the common
149
+ non-vision flow: pi writes each pasted image to
150
+ `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as text
151
+ at the cursor, so on a non-vision model they arrive as path tokens
152
+ in `event.prompt`, NOT as `event.images`. The extension scans the
153
+ prompt for those temp paths (confined to the OS temp dir — a
154
+ prompt can't trick it into reading arbitrary files), reads the
155
+ files synchronously (keeps all load() calls in one batch frame →
156
+ one vision call), and `loadDescription()`s them — so the vision
157
+ call starts the INSTANT you press enter, concurrent with the
158
+ agent's first response, not waiting for it to `read` the files
159
+ → read tool / tool_result (PRIMARY injection point)
160
+ • pi runs `read` calls in parallel (Promise.all); each read's
161
+ tool_result handler calls `loadDescription(img)` for its image
162
+ blocks and AWAITS the shared batch
163
+ • DataLoader: all `load()` calls in the same microtask frame land in
164
+ ONE batch → ONE `complete()` vision call for the whole read set
165
+ (dispatched after the microtask cascade via enqueuePostPromiseJob:
166
+ `Promise.resolve().then(() => process.nextTick(dispatch))`)
167
+ • awaits the shared batch — runs the describer during the tool-result
168
+ phase (free time: the agent is just waiting for tool results), so
169
+ the batch is COMPLETE before `context` fires → `context` is a
170
+ non-blocking cache hit, not a cold miss on the critical path
171
+ • does NOT mutate the result: the image stays in storage for kitty
172
+ inline rendering and `/resume`; the image→text swap happens in the
173
+ `context` hook (on the cloned LLM-bound payload only)
174
+ → context (FALLBACK + swap — fires before each LLM call)
175
+ • catches image blocks that didn't go through `read`'s tool_result —
176
+ user-attached images, custom extension-injected messages — and
177
+ swaps read images too (cache hits from the tool_result priming)
178
+ • `loadDescription()` is a cache hit (warmed above) or queues into the
179
+ loader's current batch; swaps images for text in the cloned LLM-bound
180
+ payload (`emitContext` does a `structuredClone`), leaving storage
181
+ intact for kitty inline rendering and `/resume`
182
+
183
+ Because the describer runs during the **tool-result phase** (before the
184
+ agent's next turn), not during the `context` transform (the critical path
185
+ right before the LLM call), the agent gets described text immediately when
186
+ its turn starts — it never waits on the describer inline.
187
+
188
+ ### Batching: the DataLoader
189
+
190
+ A single frame's image set is described with **one** vision-model call, not
191
+ one per image. `loadDescription(img)` is synchronous: on a cache miss it
192
+ pushes the image's key (hash) into the current batch and returns a memoized
193
+ promise; on a cache hit it returns the existing (in-flight or resolved)
194
+ promise. `enqueuePostPromiseJob` schedules dispatch after the microtask
195
+ cascade settles, so every `load()` caller in the frame registers its key
196
+ before the single vision call fires — exactly DataLoader's `getCurrentBatch`
197
+ + `enqueuePostPromiseJob`. The batched call sends every uncached image in a
198
+ single user message with per-image `<<<IMAGE k>>> … <<<END>>>` delimiters; the
199
+ response is parsed back into per-image descriptions (keyed by
200
+ `sha256(mime + base64)` in the per-image cache). If the vision model ignores
201
+ the delimiter format and the batched response can't be split, each unparsed
202
+ image falls back to its own single-image `complete()` call **in parallel**
203
+ (no delimiters to cooperate with) — descriptions still arrive together. Only
204
+ when a per-image call itself genuinely fails (auth, timeout, empty) does that
205
+ image degrade to `[Image: description unavailable]`; one bad image never
206
+ voids the rest.
207
+
208
+ Because pi runs parallel `read` tool calls via `Promise.all` and fires the
209
+ `tool_result` event inside that `Promise.all` (via `agent.afterToolCall` →
210
+ `finalizeExecutedToolCall`), N parallel reads' `load()` calls share ONE
211
+ microtask frame → ONE batch → ONE vision call, all resolving together.
212
+
213
+ **Failures are never cached.**
214
+
215
+ **Failures are never cached.** A genuine describer failure returns
216
+ `[Image: description unavailable]` for that turn but is NOT written to the
217
+ per-image cache, so the next turn re-attempts (and surfaces the real error
218
+ via a `ctx.ui.notify` warning instead of serving a stuck placeholder). This
219
+ avoids a transient failure poisoning the cache for the rest of the session.
220
+
221
+ **Per-image-set warning.** The `context` hook fires before every LLM turn,
222
+ and describer failures aren't cached — so without dedup the same broken
223
+ vision model would re-warn every turn. Each distinct image is warned about
224
+ at most once per session (tracked by image hash, reset on `/resume`/new
225
+ session); subsequent turns for the same failing image degrade silently to
226
+ the placeholder instead of spamming.
227
+
228
+ **Timeout scales with batch size.** The describer generates an exhaustive
229
+ multi-paragraph description per image, and the call is batched (N images →
230
+ 1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image
231
+ budget (45s × (imageCount − 1)), so a 5-image batch isn't held to the same
232
+ wall-clock budget as one image. A timeout surfaces as `describer timed out
233
+ after <N>s` rather than a misleading `stopReason "aborted"`.
234
+
235
+ **No silent truncation.** The describer prompt says "be exhaustive", so the
236
+ default `maxTokens` is **unset** — the describer uses the vision model's
237
+ declared max output (`model.maxTokens`) as the cap, rather than relying on a
238
+ provider's small omitted-default. That value is clamped so `maxTokens + 8192
239
+ <= contextWindow`: a model whose declared `maxTokens` equals its full
240
+ `contextWindow` (e.g. a custom provider that sets both to the same number)
241
+ would otherwise be rejected by the provider with a 400 (you can't request
242
+ output tokens equal to the entire context window when you also have input),
243
+ so the clamp subtracts a small input reserve. If the model still hits a token
244
+ limit (a cap you set, or the provider's hard output maximum), `stopReason`
245
+ becomes `"length"`; the describer appends a visible
246
+ `[... description truncated …]` marker to the (still useful) partial text
247
+ rather than letting a cut-off description pass as complete. For a batched call
248
+ the marker lands on the last image being emitted when the cap hit (the one cut
249
+ off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
250
+
251
+ **Aborts propagate.** The `context` hook runs in the foreground (pi awaits
252
+ it before the LLM call), so a slow describer would also make aborting a turn
253
+ slow — pi has to wait for the transform to return. The hook therefore wires
254
+ the turn's abort signal (`ctx.signal`, the active run's `AbortController`)
255
+ into the describer's `complete()` call, so a user cancel kills the in-flight
256
+ vision request immediately. A deliberate abort is not warned about (it's
257
+ not a vision-model failure) and the LLM-bound payload is left untouched since
258
+ the turn is being torn down anyway.
168
259
 
169
260
  ### Image-block formats handled
170
261
 
171
- | Hook | Image block shape | Replacement |
172
- |------|-------------------|-------------|
173
- | `tool_result` (read) | `{ type: "image", data, mimeType }` (pi-ai internal) | description text block inserted **before** the kept image |
174
- | `before_provider_request` | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | `{ type: "text", text }` |
175
- | `before_provider_request` | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | `{ type: "input_text", text }` |
176
- | `before_provider_request` | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | `{ type: "text", text }` |
262
+ | Hook | Image block shape | Handling |
263
+ |------|-------------------|----------|
264
+ | `context` (all messages) | `{ type: "image", data, mimeType }` (pi-ai internal) | undescribed replaced with description text; already-described dropped |
265
+ | `context` (all messages) | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | detected by shape → replaced with `{ type: "text", text }` |
266
+ | `context` (all messages) | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | detected by shape → replaced with `{ type: "input_text", text }` |
267
+ | `context` (all messages) | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | detected by shape → replaced with `{ type: "text", text }` |
177
268
 
178
- The describer call itself goes through pi's normal model machinery (`complete()`), **not** the agent event loop — so it never re-triggers `before_provider_request` (no recursion).
269
+ The describer call itself goes through pi's normal model machinery (`complete()`),
270
+ **not** the agent event loop — so it never re-triggers `context` (no recursion).
271
+ The `read` tool result keeps its image block untouched (kitty inline + `/resume`);
272
+ only the `context`-cloned LLM-bound payload has images swapped for text.
179
273
 
180
274
  ### Usage reporting
181
275
 
@@ -194,19 +288,24 @@ Record shape:
194
288
  model: string, provider: string,
195
289
  responseModel?: string, responseId?: string,
196
290
  usage: Usage, // { input, output, cacheRead, cacheWrite, totalTokens, cost }
291
+ imageHash: string, // representative (first) member of the batch; sha256(mime + base64), first 32 hex chars
292
+ imageHashes?: string[], // present only for batched calls (length > 1): every image the call covered
293
+ model: string, provider: string,
294
+ responseModel?: string, responseId?: string,
295
+ usage: Usage, // { input, output, cacheRead, cacheWrite, totalTokens, cost }
197
296
  // Present ONLY when Neuralwatt SSE energy comments were captured:
198
297
  energyJoules?: number, costUsd?: number,
199
298
  energyRaw?: object, mcrSessionRaw?: object, costRaw?: object,
200
299
  }
201
300
  ```
202
301
 
203
- Because `before_agent_start` fires several `describeImage()` calls fire-and-forget, the fetch interception is **refcounted** (installed only while ≥1 describe is in flight) and uses `AsyncLocalStorage` to route each teed response body to the describe call that issued it — so concurrent describes each attribute their own energy correctly without clobbering `globalThis.fetch`. When the vision model is a Neuralwatt model, `pi-neuralwatt-provider`'s own `streamNeuralwatt` tee nests on top and restores back to this interceptor; both tees read the same comment lines independently (the accepted duplication for easy filtering).
302
+ One record is emitted per **real** describer call (a batched call describing several images still emits a single record, with `imageHashes` listing every member so consumers can attribute the call's tokens/energy per image without double-counting). Because `before_agent_start` fires a batched describe fire-and-forget, the fetch interception is **refcounted** (installed only while ≥1 describe is in flight) and uses `AsyncLocalStorage` to route each teed response body to the describe call that issued it — so concurrent describes each attribute their own energy correctly without clobbering `globalThis.fetch`. When the vision model is a Neuralwatt model, `pi-neuralwatt-provider`'s own `streamNeuralwatt` tee nests on top and restores back to this interceptor; both tees read the same comment lines independently (the accepted duplication for easy filtering).
204
303
 
205
304
  ## Comparison with Alternatives
206
305
 
207
306
  | Approach | Pros | Cons |
208
307
  |----------|------|------|
209
- | **pi-vision-handoff** (this) | Provider-agnostic; pick any describer; automatic for text-only models; cached; survives across providers | Adds one extra model call per unique image |
308
+ | **pi-vision-handoff** (this) | Provider-agnostic; pick any describer; automatic for text-only models; cached; batched (one call per image set); survives across providers | Adds one extra model call per image set per turn |
210
309
  | Native vision on every model | Zero overhead | Not all models support it; you may be forced off your preferred coding model |
211
310
  | Manually describing images | No extension | Tedious; lossy; kills the "paste a screenshot" workflow |
212
311
  | The original `pi-umans-provider` handoff | Battle-tested | Hardcoded to `umans-flash` + UMANS models only |
@@ -225,15 +324,23 @@ pnpm lint:dead # Dead code detection (knip)
225
324
 
226
325
  ```
227
326
  .
228
- ├── vision-handoff.ts # Main extension: hooks, command, describer
327
+ ├── vision-handoff.ts # Wiring layer: pi hooks, /vision-handoff command
229
328
  ├── src/
230
- │ ├── index.ts # Config schema, read/write, image-block helpers (barrel)
329
+ │ ├── index.ts # Config schema, read/write, image-block helpers, batching (barrel)
330
+ │ ├── dataloader.ts # DescriptionLoader — DataLoader-batched descriptions (Disposable)
331
+ │ ├── describer.ts # Vision describer calls (runBatch / describeSingle) with `using` resource guards
332
+ │ ├── image.ts # Image hashing, MIME sniffing, clipboard-path reading
333
+ │ ├── dispose.ts # `Disposable` guard factories for `using` (fetch interceptor, timer, abort wire)
231
334
  │ ├── usage.ts # Describer usage + Neuralwatt energy capture, fetch interceptor
232
335
  │ └── vision-model-selector.ts # Interactive picker TUI component
233
336
  ├── __tests__/unit/
234
337
  │ ├── config-dir.test.ts # Ensures getAgentDir() usage
235
338
  │ ├── usage.test.ts # Energy parsing, usage records, concurrency-safe fetch routing
236
- └── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
339
+ ├── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
340
+ │ ├── dataloader.test.ts # Batch coalescing, memoization, failure eviction, Disposable reset
341
+ │ ├── describer.test.ts # stopReason handling (length → truncation marker, aborted/error)
342
+ │ ├── image.test.ts # MIME sniffing, clipboard-path confinement, file reading
343
+ │ └── dispose.test.ts # `using` guards: fetch refcount, timeout, abort-wire propagation
237
344
  ├── package.json
238
345
  ├── tsconfig.json
239
346
  ├── knip.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
@@ -31,9 +31,9 @@
31
31
  "README.md"
32
32
  ],
33
33
  "devDependencies": {
34
- "@earendil-works/pi-ai": "0.79.10",
35
- "@earendil-works/pi-coding-agent": "0.79.4",
36
- "@earendil-works/pi-tui": "0.79.4",
34
+ "@earendil-works/pi-ai": "0.80.2",
35
+ "@earendil-works/pi-coding-agent": "0.80.2",
36
+ "@earendil-works/pi-tui": "0.80.2",
37
37
  "@types/node": "25.9.1",
38
38
  "@vitest/coverage-v8": "4.1.7",
39
39
  "knip": "6.14.1",
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Facebook DataLoader pattern for image descriptions.
3
+ *
4
+ * `loadDescription(img)` returns a memoized Promise for the description and
5
+ * pushes the image's key (hash) into the CURRENT batch. All `load()` calls in
6
+ * the same execution frame (+ its microtask cascade) coalesce into ONE batch
7
+ * object. Dispatch is scheduled after the microtask queue settles (via
8
+ * `enqueuePostPromiseJob`), so every load in the frame lands in the single
9
+ * batch before the ONE vision call fires. Each load()'s promise then resolves
10
+ * with its description.
11
+ *
12
+ * Mapping: the `read` tools are the `load()` callers. Their `tool_result`
13
+ * handler awaits the shared batch — so N parallel reads (pi runs `read` via
14
+ * `Promise.all`) block on the SAME single vision call and all resolve together,
15
+ * the descriptions landing in the tool results BEFORE the agent's next turn.
16
+ * The agent's tool-result wait is free time, so this adds zero latency to the
17
+ * critical path. `context` then sees text-described tool results (no image
18
+ * blocks to swap); any remaining images (user-attached, custom-injected) are
19
+ * cache hits.
20
+ *
21
+ * All mutable state (batch, cache, turn context) lives on the instance — no
22
+ * module-level globals — and the class implements `Disposable` so a `using`
23
+ * binding (or an explicit `reset()`) cleanly abandons an in-flight batch and
24
+ * clears turn context, e.g. on session reset.
25
+ */
26
+
27
+ import type { Api, Model } from "@earendil-works/pi-ai";
28
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
29
+ import {
30
+ IMAGE_PLACEHOLDER_PREFIX,
31
+ IMAGE_PLACEHOLDER_SUFFIX,
32
+ wrapDescription,
33
+ type ExtractedImage,
34
+ type VisionHandoffConfig,
35
+ } from "./index.js";
36
+ import { imageHash } from "./image.js";
37
+ import { runBatch, describeSingle, type DescriberDeps } from "./describer.js";
38
+
39
+ /** Resolved when a description couldn't be obtained (graceful degradation).
40
+ * Failures are NOT cached, so the next turn re-attempts. */
41
+ export const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
42
+
43
+ interface DescriptionBatch {
44
+ keys: string[];
45
+ imgs: ExtractedImage[];
46
+ callbacks: { resolve: (v: string) => void; reject: (e: Error) => void }[];
47
+ }
48
+
49
+ /** Engine-provided resolver for the configured vision model. */
50
+ export interface VisionModelResolver {
51
+ (registry: ModelRegistry, ref: string): Model<Api> | null;
52
+ }
53
+
54
+ /** Dependencies the loader can't own itself (held by the engine). */
55
+ export interface LoaderDeps extends DescriberDeps {
56
+ /** Read the current config (reloaded on session_start / config writes). */
57
+ getConfig(): VisionHandoffConfig;
58
+ /** Resolve the configured vision model against a registry. */
59
+ resolveVisionModel: VisionModelResolver;
60
+ }
61
+
62
+ const resolvedMicrotask = Promise.resolve();
63
+
64
+ /** Defer `fn` until after the current microtask cascade settles, so every
65
+ * `loadDescription()` in the same frame coalesces into one batch before the
66
+ * single vision call fires. Mirrors DataLoader's enqueuePostPromiseJob: a
67
+ * Promise Job enqueues a global Job (process.nextTick), guaranteeing dispatch
68
+ * runs after "PromiseJobs" ends — after all `load()` callers in the frame
69
+ * have registered their keys. */
70
+ function enqueuePostPromiseJob(fn: () => void): void {
71
+ resolvedMicrotask.then(() => process.nextTick(fn));
72
+ }
73
+
74
+ export class DescriptionLoader implements Disposable {
75
+ private readonly cache = new Map<string, Promise<string>>();
76
+ private batch: DescriptionBatch | null = null;
77
+ private dispatchScheduled = false;
78
+ private turnModelRegistry: ModelRegistry | null = null;
79
+ private turnVisionModel: Model<Api> | null = null;
80
+ private turnSignal: AbortSignal | undefined;
81
+ private pendingTurnPrompt = "";
82
+
83
+ constructor(private readonly deps: LoaderDeps) {}
84
+
85
+ /** Bind the turn context (model registry, resolved vision model, abort signal)
86
+ * for the loader's next dispatch. Called from every handler that may trigger
87
+ * `loadDescription()`. */
88
+ bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
89
+ this.turnModelRegistry = ctx.modelRegistry;
90
+ this.turnSignal = ctx.signal;
91
+ const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, this.deps.getConfig().visionModel!);
92
+ if (resolved) this.turnVisionModel = resolved;
93
+ }
94
+
95
+ /** Capture this turn's user prompt so every image in the turn is described in
96
+ * the same request context. */
97
+ setPendingTurnPrompt(prompt: string): void {
98
+ this.pendingTurnPrompt = prompt;
99
+ }
100
+
101
+ /** Load an image's description. Returns a memoized Promise: cache hits return
102
+ * the existing (in-flight or resolved) promise; misses push the image's key
103
+ * into the current batch and schedule dispatch. Callers in the same frame
104
+ * share ONE batch → ONE vision call. Failures resolve to {@link UNAVAILABLE}
105
+ * and are NOT cached, so the next turn re-attempts. */
106
+ loadDescription(img: ExtractedImage): Promise<string> {
107
+ const hash = imageHash(img.mimeType, img.data);
108
+ const cached = this.cache.get(hash);
109
+ if (cached) return cached;
110
+
111
+ if (!this.batch) {
112
+ this.batch = { keys: [], imgs: [], callbacks: [] };
113
+ this.scheduleDispatch();
114
+ }
115
+ const batch = this.batch;
116
+ let idx = batch.keys.indexOf(hash);
117
+ if (idx === -1) {
118
+ batch.keys.push(hash);
119
+ batch.imgs.push(img);
120
+ idx = batch.keys.length - 1;
121
+ } else {
122
+ // Same image loaded twice in the frame — share one cache slot, but give
123
+ // this caller its own promise.
124
+ batch.imgs[idx] = img;
125
+ }
126
+ const promise = new Promise<string>((resolve, reject) => {
127
+ batch.callbacks.push({ resolve, reject });
128
+ });
129
+ const cfg = this.deps.getConfig();
130
+ if (this.cache.size >= cfg.cacheMax) {
131
+ const firstKey = this.cache.keys().next().value;
132
+ if (firstKey !== undefined) this.cache.delete(firstKey);
133
+ }
134
+ this.cache.set(hash, promise);
135
+ return promise;
136
+ }
137
+
138
+ /** Abandon any in-flight batch and clear turn context. Used on session reset
139
+ * (the batch's turn is gone) — also exposed via `[Symbol.dispose]` so a
140
+ * `using` binding can scope a loader lifetime. The description cache is
141
+ * preserved across turns (descriptions are stable per hash). */
142
+ reset(): void {
143
+ this.batch = null;
144
+ this.dispatchScheduled = false;
145
+ this.turnModelRegistry = null;
146
+ this.turnVisionModel = null;
147
+ this.turnSignal = undefined;
148
+ this.pendingTurnPrompt = "";
149
+ }
150
+
151
+ [Symbol.dispose](): void {
152
+ this.reset();
153
+ }
154
+
155
+ private scheduleDispatch(): void {
156
+ if (this.dispatchScheduled) return;
157
+ this.dispatchScheduled = true;
158
+ enqueuePostPromiseJob(() => this.dispatchBatch());
159
+ }
160
+
161
+ /** Dispatch the current batch: ONE batched `runBatch` vision call for every
162
+ * key collected this frame, then resolve each load()'s promise with its
163
+ * description (or {@link UNAVAILABLE} on failure). Failures are evicted from
164
+ * the cache so the next turn re-attempts. */
165
+ private async dispatchBatch(): Promise<void> {
166
+ this.dispatchScheduled = false;
167
+ const batch = this.batch;
168
+ this.batch = null;
169
+ if (!batch || batch.keys.length === 0) return;
170
+ if (!this.turnVisionModel || !this.turnModelRegistry) {
171
+ for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
172
+ return;
173
+ }
174
+ if (this.turnSignal?.aborted) {
175
+ for (let i = 0; i < batch.keys.length; i++) {
176
+ this.cache.delete(batch.keys[i]);
177
+ batch.callbacks[i].resolve(UNAVAILABLE);
178
+ }
179
+ return;
180
+ }
181
+ this.deps.setLastError(null); // clear before a fresh attempt
182
+ const misses = batch.keys.map((k, i) => ({ hash: k, img: batch.imgs[i] }));
183
+ const cfg = this.deps.getConfig();
184
+ const parsed = await runBatch(
185
+ misses,
186
+ this.pendingTurnPrompt,
187
+ this.turnVisionModel,
188
+ this.turnModelRegistry,
189
+ cfg,
190
+ this.deps,
191
+ this.turnSignal,
192
+ );
193
+ for (let i = 0; i < batch.keys.length; i++) {
194
+ const raw = parsed.get(batch.keys[i]);
195
+ if (raw) {
196
+ const final = wrapDescription(raw, cfg);
197
+ this.cache.set(batch.keys[i], Promise.resolve(final));
198
+ batch.callbacks[i].resolve(final);
199
+ } else {
200
+ // Genuine failure — do NOT cache; next turn re-attempts (and surfaces
201
+ // the real error). Resolve (not reject) with UNAVAILABLE to match the
202
+ // graceful-degradation contract and avoid unhandled rejections.
203
+ this.cache.delete(batch.keys[i]);
204
+ batch.callbacks[i].resolve(UNAVAILABLE);
205
+ }
206
+ }
207
+ }
208
+ }