@qilin-agent/compaction-image-offload 0.0.0-stage → 3.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md
5
+ /:
6
+ en: b599a8ee08c48558
7
+ zh: 4534254e1fd4cdcf
8
+ /qilin-compaction-image-offload:
9
+ en: 677cc41173025d5b
10
+ zh: 8ee4a356d326571d
11
+ /qilin-compaction-image-offload/summary:
12
+ en: 8881f7155e7f99c7
13
+ zh: 1e01354c7a7b1e68
14
+ /qilin-compaction-image-offload/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /qilin-compaction-image-offload/use-this-package:
18
+ en: 100dfe44db6bcc2c
19
+ zh: 6e9665cd10c75d7a
20
+ /qilin-compaction-image-offload/use-this-package/minimal-configuration:
21
+ en: 7e32fdf2571d190c
22
+ zh: 6b455c98d3afa3e0
23
+ /qilin-compaction-image-offload/use-this-package/what-you-can-observe:
24
+ en: 6a156ba4e44b520a
25
+ zh: a61fc63697f85095
26
+ /qilin-compaction-image-offload/use-this-package/failures-and-recovery:
27
+ en: 5e4ac1980eec905a
28
+ zh: 0284e597c82cb179
29
+ /qilin-compaction-image-offload/understand-the-implementation:
30
+ en: 560e73eece24a4de
31
+ zh: 4e99c02296be59c8
32
+ /qilin-compaction-image-offload/further-exploration:
33
+ en: 4fdfd5f29d376592
34
+ zh: cad39487903af5c0
35
+ /qilin-compaction-image-offload/model-experience:
36
+ en: 215e7ba838619b7b
37
+ zh: a311da3843709f90
38
+ /qilin-compaction-image-offload/model-experience/offloaded-request-images:
39
+ en: e57e89ce994680bd
40
+ zh: b31ac5142af08695
41
+ /qilin-compaction-image-offload/model-experience/offloaded-request-images/what-the-model-sees:
42
+ en: 54a927e19008dd58
43
+ zh: 45ec6d988b73e2fb
44
+ /qilin-compaction-image-offload/model-experience/offloaded-request-images/token-effect:
45
+ en: a1a6386bbdad684e
46
+ zh: 09a1f02f013390cd
47
+ /qilin-compaction-image-offload/model-experience/offloaded-request-images/kv-cache-effect:
48
+ en: 1ddf05ccd042aa85
49
+ zh: 0f2984a2e6b8c5c7
50
+ /qilin-compaction-image-offload/known-limitations-and-deferred-work:
51
+ en: 56e34b1ca548fa07
52
+ zh: 8ad247f5251a5461
53
+ /qilin-compaction-image-offload/known-limitations-and-deferred-work/dev-note:
54
+ en: 699a8ae4642c8f5a
55
+ zh: 19a4edd00e473c8a
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "The image offload executor for deployments composing compaction: what happens when an image-capable route rejects a request as over its image budget."
3
+ kind: "package-reference"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @qilin-agent/compaction-image-offload
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Image-heavy conversations continue when older images exceed a model route's budget. The plugin permanently replaces those images with text naming each attachment and its available read-only path, then retries without spending the provider retry budget. Later requests retain that choice across route changes, resume, and replay. Token accounting follows the logged selections, and provider cache reuse ends at the first changed message.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount this plugin in every composition that runs the agent loop with an image-capable route. The shipped `qilin` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded.
29
+
30
+ ### Minimal configuration
31
+
32
+ ```yaml
33
+ - name: '@qilin-agent/compaction-image-offload'
34
+ ```
35
+
36
+ ### What you can observe
37
+
38
+ Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. An agent retry follows a fresh `request/header` identifying a new message series. Summary retries remain inside the same compaction bracket.
39
+
40
+ ### Failures and recovery
41
+
42
+ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadImages`. It walks the surface in model request order, skips assistant nodes and images already marked, and marks that many occurrences. When nothing remains to offload it delegates on the `agent/request-error` waterfall, so downstream recovery or the ordinary turn error applies. The retry spends no provider retry budget and logs no `llm/retry` event.
43
+
44
+ -----
45
+
46
+ <a id="understand-the-implementation"></a>
47
+ ## Understand the implementation
48
+
49
+ <details>
50
+ <summary>Implementation internals — click to expand</summary>
51
+
52
+ This package owns the event declaration, reference validation, immutable image projection, selection, and retry policy. It registers `imageOffloadProjection` with Session before accepting image-offload events. The same browser-safe definition is exported from `./projection` for detached replay; the installed format catalog assembles it for offline readers. Missing registration rejects live restore, and unloading a used definition blocks further message derivation. Token measurement folds the same selections without replacement shadow prices.
53
+
54
+ Summary failures use the synchronous `compaction/summary-error` waterfall. The plugin selects only the supplied summary region and returns true after recording an omission. The compaction backend re-derives and re-prices that region before retrying; each retry omits additional retained occurrences, so recovery ends when none remain. Cancellation and unrelated selection changes reject the summary. Recorded omissions survive later failure or cancellation.
55
+
56
+ </details>
57
+
58
+ -----
59
+
60
+ <a id="further-exploration"></a>
61
+ ## Further Exploration
62
+
63
+ - [Dedicated image-offload events](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md) — durable selections, ownership, and rejected alternatives.
64
+ - [compaction seam](../compaction/README.md) — the neighboring summary and text-pruning operations.
65
+ - [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs while preserving image selections.
66
+ - [qilin-llm](../../llm/llm/README.md) — `ImageBlock.offloaded`, `IMAGE_OFFLOAD_REQUIRED`, and the placeholder projection.
67
+ - [llm-deepseek adapter](../../llm/llm-deepseek/README.md) and [llm-pi-ai adapter](../../llm/llm-pi-ai/README.md) — the route budgets that report offload counts.
68
+
69
+ -----
70
+
71
+ <a id="model-experience"></a>
72
+ ## Model Experience
73
+
74
+ ### Offloaded request images
75
+
76
+ #### What the model sees
77
+
78
+ Every selected image occurrence reaches the model as placeholder text (`offloadedImageText`) naming the attachment and its available read-only path; unselected occurrences stay images. Selections persist across requests. A new tool read may introduce a new occurrence of the same attachment without restoring the old one.
79
+
80
+ #### Token effect
81
+
82
+ An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter applies the logged selections when pricing each node. Reference-only heuristic counts exclude offload metadata.
83
+
84
+ #### KV Cache effect
85
+
86
+ A selection turns earlier images into placeholder text, so provider cache reuse ends at the first changed image for that request. The selected occurrences remain omitted afterwards.
87
+
88
+ ## Known Limitations and Deferred Work
89
+
90
+ <a id="known-limitations-and-deferred-work"></a>
91
+
92
+ - **Offloaded images never return automatically** — a larger budget, a larger route, or compaction lowering the total leaves the marks in place; recovery is the read-only path in the placeholder.
93
+ - **Every offload costs one failed attempt** — nothing plans an offload before dispatch; the route's rejection is the signal, which matches the planned move to provider-reported uncacheable images.
94
+ - **A temporary small budget offloads permanently** — a Files outage that forces the inline fallback, or a temporary switch to a small-budget route, offloads images a later route would have sent.
95
+
96
+ <a id="dev-note"></a>
97
+ ### Dev Note
98
+
99
+ <details>
100
+ <summary>Working context for maintainers — click to expand</summary>
101
+
102
+ This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
103
+
104
+ - The route-local byte checks that produce `IMAGE_OFFLOAD_REQUIRED` are provisional: once the provider reports uncacheable images itself, the adapters map that report to a count and this executor stays as it is.
105
+
106
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,106 @@
1
+ ---
2
+ description: "面向组合 compaction 的部署的图片省略执行器说明:支持图片的路由以请求超出图片预算拒绝时会发生什么。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @qilin-agent/compaction-image-offload
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 当较早的图片超出模型路由的预算时,图片密集的会话仍可继续。本插件永久将这些图片替换为注明附件及其可用只读路径的文本,然后重试,不消耗提供方重试预算。后续请求在切换路由、恢复和回放时都保留这一选择。token 计量随日志记录的选择更新,提供方缓存只能复用到第一条被修改的消息之前。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 凡是运行 agent loop(智能体循环)并带有支持图片的路由的组合,都应挂载本插件,随附的 `qilin` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek 适配器执行其 file 模式和内联回退预算,pi-ai 适配器执行其 base64 上限,各自上报需要省略的数量。
29
+
30
+ ### 最小可用组合
31
+
32
+ ```yaml
33
+ - name: '@qilin-agent/compaction-image-offload'
34
+ ```
35
+
36
+ ### 你可以观察到什么
37
+
38
+ 每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。agent 重试前会追加新的 `request/header`,标明新的消息序列。摘要重试保留在同一对压缩标记内。
39
+
40
+ ### 失败与恢复
41
+
42
+ 本插件只处理带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败。它按模型请求顺序遍历表层,跳过 assistant 节点和已经标记的图片,给指定数量的出现位置打标记。没有可省略的图片时在 `agent/request-error` waterfall 上委派下游,由下游恢复或普通的轮次错误处理。这次重试不占提供方重试预算,也不追加 `llm/retry` 事件。
43
+
44
+ -----
45
+
46
+ <a id="understand-the-implementation"></a>
47
+ ## 理解实现
48
+
49
+ <details>
50
+ <summary>实现内部——点击展开</summary>
51
+
52
+ 本包拥有事件声明、引用校验、不可变图片投影、选图和重试策略。它在接受图片省略事件前向 Session 注册 `imageOffloadProjection`。相同的浏览器安全处理器通过 `./projection` 导出,供独立回放使用,当前格式目录为离线读取器装配该处理器。缺少注册时拒绝实时恢复,卸载已经使用的处理器后拒绝继续派生消息。token 测量应用同一份选择,不依赖替换影子价格。
53
+
54
+ 摘要失败使用同步的 `compaction/summary-error` waterfall。插件只在传入的摘要选区内选图,记录省略后返回 true。压缩后端重新派生该选区并计价,然后重试。每次重试都会继续省略保留的图片,没有可省略的位置时结束恢复。取消或无关的选区变更会使摘要失败。已经记录的省略在后续失败或取消后仍然有效。
55
+
56
+ </details>
57
+
58
+ -----
59
+
60
+ <a id="further-exploration"></a>
61
+ ## 进一步探索
62
+
63
+ - [独立的图片省略事件](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md),记录持久选择、职责和被否决的方案。
64
+ - [compaction seam](../compaction/README.zh.md),相邻的摘要和文本剪枝操作。
65
+ - [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md),保留图片选择并修剪工具输出的兄弟执行器。
66
+ - [qilin-llm](../../llm/llm/README.zh.md)——`ImageBlock.offloaded`、`IMAGE_OFFLOAD_REQUIRED` 与占位投影。
67
+ - [llm-deepseek 适配器](../../llm/llm-deepseek/README.zh.md)与 [llm-pi-ai 适配器](../../llm/llm-pi-ai/README.zh.md)——上报省略数量的路由预算。
68
+
69
+ -----
70
+
71
+ <a id="model-experience"></a>
72
+ ## 模型体验
73
+
74
+ ### 已省略的请求图片
75
+
76
+ #### 模型看到的内容
77
+
78
+ 每个选中的图片出现位置以占位文本(`offloadedImageText`)到达模型,文本注明附件及其可用只读路径,未选中的位置仍是图片。选择在后续请求中持续生效。新的工具读取可以引入同一附件的新出现位置,不会恢复旧位置。
79
+
80
+ #### Token 影响
81
+
82
+ 被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 为每个节点定价时应用日志记录的选择。仅针对引用的启发式计数不计入省略元数据。
83
+
84
+ #### KV Cache 影响
85
+
86
+ 一次选择把较早的图片换成占位文本,该请求的提供方缓存复用因此止于第一张被修改的图片。所选位置之后仍保持省略。
87
+
88
+ ## 已知限制与延期工作
89
+
90
+ <a id="known-limitations-and-deferred-work"></a>
91
+
92
+ - **被省略的图片不会自动回归**——更大的预算、更大的路由或 compaction 降低总量都不会移除标记;恢复手段是占位文本中的只读路径。
93
+ - **每次省略都要先失败一次**——发送前没有任何规划,路由的拒绝就是信号,这和计划中改由 provider 报告无法缓存图片的方向一致。
94
+ - **临时的小预算会永久省略**——迫使内联回退的 Files 故障,或临时切到小预算路由,会省略后来的路由本可以发送的图片。
95
+
96
+ <a id="dev-note"></a>
97
+ ### 开发备注
98
+
99
+ <details>
100
+ <summary>维护者工作背景——点击展开</summary>
101
+
102
+ 本开发备注是非权威的工作背景:给维护者的备注和未决问题。已交付的行为与被接受的理由见上文各节、包代码和链接的 Agent Note。
103
+
104
+ - 产生 `IMAGE_OFFLOAD_REQUIRED` 的路由本地字节检查是过渡实现:provider 自己报告无法缓存的图片之后,adapter 把该报告换算成数量,本执行器保持不变。
105
+
106
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,154 @@
1
+ import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError } from "@qilin-agent/llm";
2
+ import { deepFreeze } from "@qilin-agent/util-values";
3
+ //#region lib/types/image-offload.js
4
+ /** Select and log permanent image omissions in current model-request order. */
5
+ /**
6
+ * Record one decision omitting the oldest retained input-image occurrences.
7
+ * Assistant nodes carry model output and are excluded. Image indexes count
8
+ * every occurrence, including previously offloaded ones, within each message.
9
+ * @param session - session whose next request applies the decision.
10
+ * @param sourceEventSeqs - input message events in the failed request's order.
11
+ * @param count - additional retained occurrences the adapter needs omitted.
12
+ * @returns whether any occurrence remained to offload.
13
+ */
14
+ function offloadOldestImages(session, sourceEventSeqs, count) {
15
+ const targets = [];
16
+ for (const seq of sourceEventSeqs) {
17
+ if (count === 0) break;
18
+ const event = session.eventAt(seq);
19
+ if (event.type !== "user/message" && event.type !== "tool/result") continue;
20
+ const message = session.deriveEventMessage(event);
21
+ const imageIndexes = [];
22
+ let imageIndex = 0;
23
+ const visit = (blocks) => {
24
+ for (const block of blocks) {
25
+ if (count === 0) break;
26
+ if (block.type === "image") {
27
+ if (block.offloaded !== true) {
28
+ imageIndexes.push(imageIndex);
29
+ count -= 1;
30
+ }
31
+ imageIndex += 1;
32
+ }
33
+ }
34
+ };
35
+ visit(message.content);
36
+ if (imageIndexes.length > 0) targets.push({
37
+ seq,
38
+ imageIndexes
39
+ });
40
+ }
41
+ if (targets.length === 0) return false;
42
+ session.append("image/offload", { targets });
43
+ return true;
44
+ }
45
+ //#endregion
46
+ //#region lib/types/project-message.js
47
+ /** Immutable application of the image occurrences recorded by image/offload. */
48
+ /**
49
+ * Project selected image occurrences to immutable offloaded blocks.
50
+ * @param message - message projected before this decision.
51
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
52
+ * @returns an immutable message with the same identity and selected images marked.
53
+ * @throws when a selected occurrence is missing or already offloaded.
54
+ */
55
+ function offloadMessageImages(message, indexes) {
56
+ let imageIndex = 0;
57
+ let selected = 0;
58
+ const visit = (blocks) => {
59
+ let next;
60
+ for (const [index, block] of blocks.entries()) {
61
+ let projected = block;
62
+ if (block.type === "image") {
63
+ if (imageIndex === indexes[selected]) {
64
+ if (block.offloaded === true) throw new Error(`image/offload: image index ${imageIndex} is already offloaded`);
65
+ projected = {
66
+ ...block,
67
+ offloaded: true
68
+ };
69
+ selected += 1;
70
+ }
71
+ imageIndex += 1;
72
+ }
73
+ if (projected !== block) next ??= blocks.slice(0, index);
74
+ next?.push(projected);
75
+ }
76
+ return next ?? blocks;
77
+ };
78
+ const content = visit(message.content);
79
+ if (selected !== indexes.length) throw new Error(`image/offload: image index ${indexes[selected]} does not exist`);
80
+ return deepFreeze({
81
+ ...message,
82
+ content
83
+ });
84
+ }
85
+ //#endregion
86
+ //#region lib/types/projection.js
87
+ /** Browser-safe durable image selection declaration and pure replay definition. */
88
+ /** Whether a durable value is a JSON object. */
89
+ function isRecord(value) {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value);
91
+ }
92
+ /** Whether a durable occurrence index or sequence is canonical. */
93
+ function isIndex(value) {
94
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
95
+ }
96
+ /** Atomic validation and reconstruction shared by live sessions and detached replay. */
97
+ const imageOffloadProjection = {
98
+ type: "image/offload",
99
+ project(event, context) {
100
+ const data = event.data;
101
+ if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data["targets"]) || data["targets"].length === 0) throw new Error("image/offload: data must contain a nonempty targets array");
102
+ const messages = /* @__PURE__ */ new Map();
103
+ const nodes = new Set(context.nodes);
104
+ for (const target of data["targets"]) {
105
+ if (!isRecord(target) || Object.keys(target).length !== 2 || !isIndex(target["seq"]) || !Array.isArray(target["imageIndexes"]) || target["imageIndexes"].length === 0) throw new Error("image/offload: each target must contain a seq and nonempty imageIndexes");
106
+ const seq = target["seq"];
107
+ if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`);
108
+ if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`);
109
+ const source = context.events[seq - context.baseSeq];
110
+ if (source?.type !== "user/message" && source?.type !== "tool/result") throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`);
111
+ let previous = -1;
112
+ for (const index of target["imageIndexes"]) {
113
+ if (!isIndex(index) || index <= previous) throw new Error("image/offload: imageIndexes must be strictly increasing non-negative safe integers");
114
+ previous = index;
115
+ }
116
+ const message = context.messages.get(seq) ?? (source.type === "user/message" ? source.data : source.data.message);
117
+ messages.set(seq, offloadMessageImages(message, target["imageIndexes"]));
118
+ }
119
+ return messages;
120
+ }
121
+ };
122
+ //#endregion
123
+ //#region lib/types/index.js
124
+ /**
125
+ * Image offload executor for the compaction seam. When an image-capable route
126
+ * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one
127
+ * `image/offload` decision selecting the oldest retained input occurrences
128
+ * and retries through the agent or compaction summary error waterfall. Every route
129
+ * sends placeholder text for those occurrences in subsequent requests.
130
+ *
131
+ * @module @qilin-agent/compaction-image-offload
132
+ */
133
+ const name = "compaction-image-offload";
134
+ const inject = ["agents", "sessions"];
135
+ /**
136
+ * Mount agent and summary recovery listeners without configuration.
137
+ * @param ctx - the plugin context.
138
+ */
139
+ function apply(ctx) {
140
+ ctx.sessions.registerMessageProjection(imageOffloadProjection);
141
+ ctx.on("agent/request-error", ({ agent, failure }, next) => {
142
+ if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === void 0) return next();
143
+ if (!offloadOldestImages(agent.session, agent.session.surface.nodes, failure.offloadImages)) return next();
144
+ return Promise.resolve({ kind: "retry" });
145
+ });
146
+ ctx.on("compaction/summary-error", ({ session, sourceEventSeqs, error, signal }, next) => {
147
+ if (!(error instanceof LlmError) || error.code !== IMAGE_OFFLOAD_REQUIRED_CODE || error.failure.offloadImages === void 0) return next();
148
+ signal?.throwIfAborted();
149
+ if (!offloadOldestImages(session, sourceEventSeqs, error.failure.offloadImages)) return next();
150
+ return true;
151
+ });
152
+ }
153
+ //#endregion
154
+ export { apply, inject, name };
@@ -0,0 +1,13 @@
1
+ /** Select and log permanent image omissions in current model-request order. */
2
+ import type { Session, SessionSeq } from '@qilin-agent/session';
3
+ /**
4
+ * Record one decision omitting the oldest retained input-image occurrences.
5
+ * Assistant nodes carry model output and are excluded. Image indexes count
6
+ * every occurrence, including previously offloaded ones, within each message.
7
+ * @param session - session whose next request applies the decision.
8
+ * @param sourceEventSeqs - input message events in the failed request's order.
9
+ * @param count - additional retained occurrences the adapter needs omitted.
10
+ * @returns whether any occurrence remained to offload.
11
+ */
12
+ export declare function offloadOldestImages(session: Session, sourceEventSeqs: readonly SessionSeq[], count: number): boolean;
13
+ //# sourceMappingURL=image-offload.d.ts.map
@@ -0,0 +1,47 @@
1
+ /** Select and log permanent image omissions in current model-request order. */
2
+ /**
3
+ * Record one decision omitting the oldest retained input-image occurrences.
4
+ * Assistant nodes carry model output and are excluded. Image indexes count
5
+ * every occurrence, including previously offloaded ones, within each message.
6
+ * @param session - session whose next request applies the decision.
7
+ * @param sourceEventSeqs - input message events in the failed request's order.
8
+ * @param count - additional retained occurrences the adapter needs omitted.
9
+ * @returns whether any occurrence remained to offload.
10
+ */
11
+ export function offloadOldestImages(session, sourceEventSeqs, count) {
12
+ const targets = [];
13
+ for (const seq of sourceEventSeqs) {
14
+ if (count === 0)
15
+ break;
16
+ // Existing Session history read; migration deferred. Current surface nodes index the durable log.
17
+ // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
18
+ const event = session.eventAt(seq);
19
+ if (event.type !== 'user/message' && event.type !== 'tool/result')
20
+ continue;
21
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- both input node types produce a message
22
+ const message = session.deriveEventMessage(event);
23
+ const imageIndexes = [];
24
+ let imageIndex = 0;
25
+ const visit = (blocks) => {
26
+ for (const block of blocks) {
27
+ if (count === 0)
28
+ break;
29
+ if (block.type === 'image') {
30
+ if (block.offloaded !== true) {
31
+ imageIndexes.push(imageIndex);
32
+ count -= 1;
33
+ }
34
+ imageIndex += 1;
35
+ }
36
+ }
37
+ };
38
+ visit(message.content);
39
+ if (imageIndexes.length > 0)
40
+ targets.push({ seq, imageIndexes });
41
+ }
42
+ if (targets.length === 0)
43
+ return false;
44
+ session.append('image/offload', { targets });
45
+ return true;
46
+ }
47
+ //# sourceMappingURL=image-offload.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Image offload executor for the compaction seam. When an image-capable route
3
+ * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one
4
+ * `image/offload` decision selecting the oldest retained input occurrences
5
+ * and retries through the agent or compaction summary error waterfall. Every route
6
+ * sends placeholder text for those occurrences in subsequent requests.
7
+ *
8
+ * @module @qilin-agent/compaction-image-offload
9
+ */
10
+ import type { Context } from '@qilin-agent/kylin';
11
+ export declare const name = "compaction-image-offload";
12
+ export declare const inject: string[];
13
+ /**
14
+ * Mount agent and summary recovery listeners without configuration.
15
+ * @param ctx - the plugin context.
16
+ */
17
+ export declare function apply(ctx: Context): void;
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Image offload executor for the compaction seam. When an image-capable route
3
+ * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one
4
+ * `image/offload` decision selecting the oldest retained input occurrences
5
+ * and retries through the agent or compaction summary error waterfall. Every route
6
+ * sends placeholder text for those occurrences in subsequent requests.
7
+ *
8
+ * @module @qilin-agent/compaction-image-offload
9
+ */
10
+ import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError } from '@qilin-agent/llm';
11
+ import { offloadOldestImages } from "./image-offload.js";
12
+ import { imageOffloadProjection } from "./projection.js";
13
+ export const name = 'compaction-image-offload';
14
+ export const inject = ['agents', 'sessions'];
15
+ /**
16
+ * Mount agent and summary recovery listeners without configuration.
17
+ * @param ctx - the plugin context.
18
+ */
19
+ export function apply(ctx) {
20
+ ctx.sessions.registerMessageProjection(imageOffloadProjection);
21
+ ctx.on('agent/request-error', ({ agent, failure }, next) => {
22
+ if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined)
23
+ return next();
24
+ // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event.
25
+ if (!offloadOldestImages(agent.session, agent.session.surface.nodes, failure.offloadImages))
26
+ return next();
27
+ return Promise.resolve({ kind: 'retry' });
28
+ });
29
+ ctx.on('compaction/summary-error', ({ session, sourceEventSeqs, error, signal }, next) => {
30
+ if (!(error instanceof LlmError)
31
+ || error.code !== IMAGE_OFFLOAD_REQUIRED_CODE
32
+ || error.failure.offloadImages === undefined)
33
+ return next();
34
+ signal?.throwIfAborted();
35
+ if (!offloadOldestImages(session, sourceEventSeqs, error.failure.offloadImages))
36
+ return next();
37
+ return true;
38
+ });
39
+ }
40
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ /** Immutable application of the image occurrences recorded by image/offload. */
2
+ import type { Message } from '@qilin-agent/llm';
3
+ /**
4
+ * Project selected image occurrences to immutable offloaded blocks.
5
+ * @param message - message projected before this decision.
6
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
7
+ * @returns an immutable message with the same identity and selected images marked.
8
+ * @throws when a selected occurrence is missing or already offloaded.
9
+ */
10
+ export declare function offloadMessageImages(message: Message, indexes: readonly number[]): Message;
11
+ //# sourceMappingURL=project-message.d.ts.map
@@ -0,0 +1,37 @@
1
+ /** Immutable application of the image occurrences recorded by image/offload. */
2
+ import { deepFreeze } from '@qilin-agent/util-values';
3
+ /**
4
+ * Project selected image occurrences to immutable offloaded blocks.
5
+ * @param message - message projected before this decision.
6
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
7
+ * @returns an immutable message with the same identity and selected images marked.
8
+ * @throws when a selected occurrence is missing or already offloaded.
9
+ */
10
+ export function offloadMessageImages(message, indexes) {
11
+ let imageIndex = 0;
12
+ let selected = 0;
13
+ const visit = (blocks) => {
14
+ let next;
15
+ for (const [index, block] of blocks.entries()) {
16
+ let projected = block;
17
+ if (block.type === 'image') {
18
+ if (imageIndex === indexes[selected]) {
19
+ if (block.offloaded === true)
20
+ throw new Error(`image/offload: image index ${imageIndex} is already offloaded`);
21
+ projected = { ...block, offloaded: true };
22
+ selected += 1;
23
+ }
24
+ imageIndex += 1;
25
+ }
26
+ if (projected !== block)
27
+ next ??= blocks.slice(0, index);
28
+ next?.push(projected);
29
+ }
30
+ return next ?? blocks;
31
+ };
32
+ const content = visit(message.content);
33
+ if (selected !== indexes.length)
34
+ throw new Error(`image/offload: image index ${indexes[selected]} does not exist`);
35
+ return deepFreeze({ ...message, content });
36
+ }
37
+ //# sourceMappingURL=project-message.js.map
@@ -0,0 +1,27 @@
1
+ /** Browser-safe durable image selection declaration and pure replay definition. */
2
+ import type { SessionSeq } from '@qilin-agent/session/types';
3
+ import type { SessionMessageProjection } from '@qilin-agent/session/surface';
4
+ /** Exact input-image occurrences selected by one durable offload decision. */
5
+ export interface ImageOffloadTarget {
6
+ /** Current message-producing event containing these occurrences. */
7
+ seq: SessionSeq;
8
+ /** Zero-based depth-first image indexes within the immutable message. */
9
+ imageIndexes: number[];
10
+ }
11
+ declare module '@qilin-agent/session/types' {
12
+ interface SessionEventMap {
13
+ /**
14
+ * Permanently omit selected input-image occurrences from subsequent model requests.
15
+ * Targets name unique current user/message or tool/result nodes. Nonempty, strictly
16
+ * increasing indexes count all images in depth-first order, including nested tool
17
+ * results and already omitted images. Message nodes and identities remain unchanged.
18
+ * @messageProjection
19
+ */
20
+ 'image/offload': {
21
+ targets: ImageOffloadTarget[];
22
+ };
23
+ }
24
+ }
25
+ /** Atomic validation and reconstruction shared by live sessions and detached replay. */
26
+ export declare const imageOffloadProjection: SessionMessageProjection<'image/offload'>;
27
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,49 @@
1
+ /** Browser-safe durable image selection declaration and pure replay definition. */
2
+ import { offloadMessageImages } from "./project-message.js";
3
+ /** Whether a durable value is a JSON object. */
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /** Whether a durable occurrence index or sequence is canonical. */
8
+ function isIndex(value) {
9
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
10
+ }
11
+ /** Atomic validation and reconstruction shared by live sessions and detached replay. */
12
+ export const imageOffloadProjection = {
13
+ type: 'image/offload',
14
+ project(event, context) {
15
+ const data = event.data;
16
+ if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data['targets']) || data['targets'].length === 0) {
17
+ throw new Error('image/offload: data must contain a nonempty targets array');
18
+ }
19
+ const messages = new Map();
20
+ const nodes = new Set(context.nodes);
21
+ for (const target of data['targets']) {
22
+ if (!isRecord(target) || Object.keys(target).length !== 2 || !isIndex(target['seq'])
23
+ || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) {
24
+ throw new Error('image/offload: each target must contain a seq and nonempty imageIndexes');
25
+ }
26
+ const seq = target['seq'];
27
+ if (messages.has(seq))
28
+ throw new Error(`image/offload: duplicate target seq ${seq}`);
29
+ if (!nodes.has(seq))
30
+ throw new Error(`image/offload: target seq ${seq} is not a current surface node`);
31
+ const source = context.events[seq - context.baseSeq];
32
+ if (source?.type !== 'user/message' && source?.type !== 'tool/result') {
33
+ throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`);
34
+ }
35
+ let previous = -1;
36
+ for (const index of target['imageIndexes']) {
37
+ if (!isIndex(index) || index <= previous) {
38
+ throw new Error('image/offload: imageIndexes must be strictly increasing non-negative safe integers');
39
+ }
40
+ previous = index;
41
+ }
42
+ const message = context.messages.get(seq)
43
+ ?? (source.type === 'user/message' ? source.data : source.data.message);
44
+ messages.set(seq, offloadMessageImages(message, target['imageIndexes']));
45
+ }
46
+ return messages;
47
+ },
48
+ };
49
+ //# sourceMappingURL=projection.js.map
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@qilin-agent/compaction-image-offload",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry",
4
+ "version": "3.1.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kkutysllb/QiLin.git",
11
+ "directory": "packages/compaction/compaction-image-offload"
12
+ },
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "types": "lib/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./package.json": "./package.json",
25
+ "./projection": {
26
+ "types": "./lib/types/projection.d.ts",
27
+ "default": "./lib/types/projection.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "lib/index.js",
32
+ "lib/types/**/*.js",
33
+ "lib/types/**/*.d.ts"
34
+ ],
35
+ "license": "MIT",
36
+ "peerDependencies": {
37
+ "@qilin-agent/agent": "3.1.3",
38
+ "@qilin-agent/compaction": "3.1.3",
39
+ "@qilin-agent/kylin": "~4.0.3",
40
+ "@qilin-agent/session": "3.1.3",
41
+ "@qilin-agent/llm": "3.1.3"
42
+ },
43
+ "devDependencies": {
44
+ "@qilin-agent/agent": "3.1.3",
45
+ "@qilin-agent/agent-loop-testkit": "3.1.3",
46
+ "@qilin-agent/agent-loop": "3.1.3",
47
+ "@qilin-agent/kylin": "~4.0.3",
48
+ "@qilin-agent/attachment": "3.1.3",
49
+ "@qilin-agent/compaction-basic": "3.1.3",
50
+ "@qilin-agent/compaction": "3.1.3",
51
+ "@qilin-agent/llm-deepseek": "3.1.3",
52
+ "@qilin-agent/llm": "3.1.3",
53
+ "@qilin-agent/session-projection": "3.1.3",
54
+ "@qilin-agent/session": "3.1.3",
55
+ "@qilin-agent/token-meter": "3.1.3"
56
+ },
57
+ "dependencies": {
58
+ "@qilin-agent/util-values": "3.1.3"
59
+ }
6
60
  }