@elliotsystems/eai 0.1.9 → 0.1.14
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/bin/eai +7 -0
- package/lib/stage.js +92 -11
- package/package.json +2 -2
- package/plugin/opencode-plugin/src/compaction.ts +600 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +44 -4
- package/plugin/opencode-plugin/src/index.ts +384 -5
- package/plugin/opencode-plugin/src/session-affinity.ts +32 -0
package/bin/eai
CHANGED
|
@@ -303,6 +303,13 @@ if (!env.OPENCODE_CONFIG_DIR) {
|
|
|
303
303
|
// our own below. This is what lets OpenCode stay stock.
|
|
304
304
|
env.OPENCODE_DISABLE_TERMINAL_TITLE = "1"
|
|
305
305
|
|
|
306
|
+
// The TUI owns the whole terminal, so anything the runtime or a dependency prints
|
|
307
|
+
// to stderr lands on top of the screen. Node's own runtime warnings are the common
|
|
308
|
+
// case: `DEP0169 url.parse()` from a dependency printed three lines over the prompt
|
|
309
|
+
// on every launch. Switched off unless the user set the variable themselves, so
|
|
310
|
+
// `NODE_NO_WARNINGS=0` still brings them back when debugging.
|
|
311
|
+
if (env.NODE_NO_WARNINGS === undefined) env.NODE_NO_WARNINGS = "1"
|
|
312
|
+
|
|
306
313
|
// Claim the title before the child paints anything.
|
|
307
314
|
if (process.stdout.isTTY) process.stdout.write(ELLIOT_TITLE)
|
|
308
315
|
|
package/lib/stage.js
CHANGED
|
@@ -29,6 +29,62 @@ const fs = require("fs")
|
|
|
29
29
|
const os = require("os")
|
|
30
30
|
const path = require("path")
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The JEV (fast-jev-compaction-opencode) plugin spec. NOT staged by default:
|
|
34
|
+
* pass it as `jevPlugin` to stage it alongside Elliot's own branding plugin.
|
|
35
|
+
* It is off because the plugin writes its own log lines onto the TUI (and its
|
|
36
|
+
* `github:` spec also triggers a Node `url.parse()` deprecation warning at
|
|
37
|
+
* load), with no option to silence either. Turn it on again once it can log to
|
|
38
|
+
* a file only.
|
|
39
|
+
*
|
|
40
|
+
* Registering it here does NOT mean compaction works: as of this writing its
|
|
41
|
+
* default backend ("typesafe") has no configured credential on a stock
|
|
42
|
+
* install, so its own compaction hook fails closed and OpenCode's native
|
|
43
|
+
* compaction runs instead -- confirmed by reading its own log output, not
|
|
44
|
+
* claimed from its source alone. A plugin that fails to load or fails inside
|
|
45
|
+
* its own hook does not block startup or touch Elliot's own plugin entry;
|
|
46
|
+
* OpenCode loads each plugin independently and logs a per-plugin failure
|
|
47
|
+
* rather than refusing to start (see opencode.log's own "failed to load
|
|
48
|
+
* plugin" entries for Elliot's branding plugin on a missing gatewayAlias,
|
|
49
|
+
* which never affected the rest of the session).
|
|
50
|
+
*
|
|
51
|
+
* A bare string, not a [spec, options] tuple: unlike Elliot's own plugin, it
|
|
52
|
+
* takes no required option here, so there is nothing to pass.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_JEV_PLUGIN_SPEC = "github:overbit/fast-jev-compaction-opencode"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Credential-shaped keys this function refuses to ever write into a staged
|
|
58
|
+
* config file. Checked by NAME, not by guessing at a value's shape -- a
|
|
59
|
+
* deliberately conservative denylist, since the cost of missing a future
|
|
60
|
+
* secret-shaped option name is much higher than the cost of a false
|
|
61
|
+
* positive dropping a harmless key that happened to share a name.
|
|
62
|
+
*/
|
|
63
|
+
const JEV_PLUGIN_SECRET_OPTION_KEYS = new Set(["apiKey", "localApiKey"])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `opts.jevPluginOptions`, minus any credential-shaped key -- see
|
|
67
|
+
* DEFAULT_JEV_PLUGIN_SPEC's own docstring above for why a secret should
|
|
68
|
+
* never reach here in the first place. `undefined`/non-object input
|
|
69
|
+
* returns `undefined` (no options tuple at all, same as omitting the
|
|
70
|
+
* field), never throws -- this must never be the reason staging fails.
|
|
71
|
+
*/
|
|
72
|
+
function jevPluginOptionsExcludingSecrets(raw) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
|
|
74
|
+
const clean = {}
|
|
75
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
76
|
+
if (JEV_PLUGIN_SECRET_OPTION_KEYS.has(key)) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`eai: refusing to write "${key}" into the staged JEV plugin config -- ` +
|
|
79
|
+
`credentials must be set as an environment variable (e.g. TYPESAFE_API_KEY), never in a config file.\n`,
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
clean[key] = value
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(clean).length > 0 ? clean : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
32
88
|
/**
|
|
33
89
|
* The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
|
|
34
90
|
* `gateway_alias` field -- `eai setup` writes it there straight out of
|
|
@@ -139,6 +195,27 @@ function stageOverlay(opts) {
|
|
|
139
195
|
// from. See readGatewayAlias's own docstring for why `undefined` must not
|
|
140
196
|
// become a guessed `"elliot"`.
|
|
141
197
|
const gatewayAlias = opts.gatewayAlias
|
|
198
|
+
// OFF unless a caller passes a spec (the tests do; pass DEFAULT_JEV_PLUGIN_SPEC
|
|
199
|
+
// to turn it on). It was staged for every install, but the plugin copies each
|
|
200
|
+
// of its log lines to the console and has no option to stop it, so on every
|
|
201
|
+
// launch `[fast-jev-compaction-opencode] initialized {...}` was drawn into the
|
|
202
|
+
// TUI prompt box -- for a compaction hook that fails closed on a stock install
|
|
203
|
+
// anyway. See DEFAULT_JEV_PLUGIN_SPEC's docstring.
|
|
204
|
+
const jevPlugin = opts.jevPlugin || null
|
|
205
|
+
// Non-secret backend selection ONLY -- e.g. { backend: "systemone",
|
|
206
|
+
// localBaseUrl: "...", localModel: "..." } to point the plugin at a local
|
|
207
|
+
// decision model instead of its own TypeSafe-hosted default. NEVER a
|
|
208
|
+
// credential: the plugin already reads TYPESAFE_API_KEY straight from
|
|
209
|
+
// process.env on every call (jev-compaction-opencode's own client.ts),
|
|
210
|
+
// and the OpenCode child process already inherits this launcher's full
|
|
211
|
+
// environment (bin/eai's `childProcess.spawn(..., { env })`) -- there is
|
|
212
|
+
// no path by which a secret needs to pass through this function, through
|
|
213
|
+
// a JSON file this function writes, or through anything this launcher
|
|
214
|
+
// logs. `jevPluginOptionsExcludingSecrets` enforces that as a real
|
|
215
|
+
// invariant, not just an absence of code: an `apiKey`/`localApiKey` key
|
|
216
|
+
// here is dropped and a warning is written to stderr rather than ever
|
|
217
|
+
// reaching a file on disk.
|
|
218
|
+
const jevPluginOptions = jevPluginOptionsExcludingSecrets(opts.jevPluginOptions)
|
|
142
219
|
|
|
143
220
|
const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
|
|
144
221
|
const themeSource = path.join(packageRoot, "config", "themes")
|
|
@@ -150,13 +227,13 @@ function stageOverlay(opts) {
|
|
|
150
227
|
|
|
151
228
|
try {
|
|
152
229
|
// `.staged` records the version, a content fingerprint of the source this
|
|
153
|
-
// tree was built from,
|
|
154
|
-
//
|
|
155
|
-
// to startup, but skipping it on
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
|
|
230
|
+
// tree was built from, the gateway alias baked into the written config,
|
|
231
|
+
// AND the JEV plugin spec -- re-staging on every launch would add
|
|
232
|
+
// hundreds of file copies to startup, but skipping it on any of these
|
|
233
|
+
// changing would leave a machine stuck on a stale tui.json/opencode.json
|
|
234
|
+
// (the alias case is the incident this already protects against; the JEV
|
|
235
|
+
// spec is the same risk the moment that spec is ever overridden).
|
|
236
|
+
const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""} ${jevPlugin || ""} ${JSON.stringify(jevPluginOptions || {})}`.trim()
|
|
160
237
|
const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
|
|
161
238
|
if (!done) {
|
|
162
239
|
fs.rmSync(root, { recursive: true, force: true })
|
|
@@ -176,9 +253,13 @@ function stageOverlay(opts) {
|
|
|
176
253
|
// OUT of the array entirely rather than included in a form guaranteed
|
|
177
254
|
// to fail — "plain OpenCode chrome" is the same visible outcome either
|
|
178
255
|
// way, but without logging a load error on every single launch.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
256
|
+
// JEV is appended independently of gatewayAlias: it has no such
|
|
257
|
+
// required option (see DEFAULT_JEV_PLUGIN_SPEC), so there is nothing
|
|
258
|
+
// here that can fail validation the way a bare Elliot plugin path did.
|
|
259
|
+
const plugin = [
|
|
260
|
+
...(gatewayAlias ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]] : []),
|
|
261
|
+
...(jevPlugin ? [jevPluginOptions ? [jevPlugin, jevPluginOptions] : jevPlugin] : []),
|
|
262
|
+
]
|
|
182
263
|
fs.writeFileSync(
|
|
183
264
|
path.join(root, "tui.json"),
|
|
184
265
|
JSON.stringify(
|
|
@@ -207,4 +288,4 @@ function stageOverlay(opts) {
|
|
|
207
288
|
}
|
|
208
289
|
}
|
|
209
290
|
|
|
210
|
-
module.exports = { stageOverlay, stageRoot, readGatewayAlias }
|
|
291
|
+
module.exports = { stageOverlay, stageRoot, readGatewayAlias, DEFAULT_JEV_PLUGIN_SPEC }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"eai": "./bin/eai"
|
|
@@ -27,6 +27,6 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"opencode-ai": "1.19.
|
|
30
|
+
"opencode-ai": "1.19.5"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast Jev Compaction — the ALGORITHM only (spec: `specs/2026-10-29-fast-
|
|
3
|
+
* jev-compaction-pilot.md`, task T6). Ported from the public `fast-jev-
|
|
4
|
+
* compaction` project's README (score each tool call/result via a yes/no
|
|
5
|
+
* judge, drop or truncate the ones no longer needed, keep everything else
|
|
6
|
+
* verbatim — never summarize/rewrite text) onto EAI's own message shape.
|
|
7
|
+
*
|
|
8
|
+
* This module is deliberately TRANSPORT-AGNOSTIC: no HTTP, no fetch, no
|
|
9
|
+
* knowledge of OpenRouter/Jev/the T3 backend endpoint. The two yes/no
|
|
10
|
+
* questions the algorithm needs answered for each candidate tool call are
|
|
11
|
+
* asked through the injected `asker` function parameter — the caller (T7)
|
|
12
|
+
* supplies an adapter that POSTs to the team-scoped backend proxy. Keeping
|
|
13
|
+
* the network entirely outside this file is what makes it unit-testable
|
|
14
|
+
* (T8) without a network mock framework, and keeps this module reusable if
|
|
15
|
+
* the transport ever changes.
|
|
16
|
+
*
|
|
17
|
+
* ## Shape this operates on
|
|
18
|
+
*
|
|
19
|
+
* A simplified, content-block message shape (`CompactionMessage`/
|
|
20
|
+
* `ContentBlock`) rather than OpenCode's own `{ info, parts }` wire shape —
|
|
21
|
+
* T7's adapter is responsible for converting one into the other before and
|
|
22
|
+
* after calling `compactMessages`. `tool_use`/`tool_result` blocks are
|
|
23
|
+
* paired by id, matching how Claude-style content blocks represent a tool
|
|
24
|
+
* round trip (an assistant message's `tool_use` block, answered by a later
|
|
25
|
+
* message's `tool_result` block carrying the same id).
|
|
26
|
+
*
|
|
27
|
+
* `info` is carried through unchanged on each `CompactionMessage` so this
|
|
28
|
+
* module can reuse `isBlockedAssistantTurn` (see "Guardrail interplay"
|
|
29
|
+
* below) without needing to know anything else about OpenCode's message
|
|
30
|
+
* shape.
|
|
31
|
+
*
|
|
32
|
+
* ## Algorithm, in order
|
|
33
|
+
*
|
|
34
|
+
* 1. Pin message 0 ("the first message"), the first `user`-role message
|
|
35
|
+
* (covers the case where index 0 is a separate system prompt), and the
|
|
36
|
+
* newest `preserveRecentMessages` messages (default 6).
|
|
37
|
+
* 2. Pair every `tool_use` block with its `tool_result` block by id. A pair
|
|
38
|
+
* is a CANDIDATE only when neither its call message nor its result
|
|
39
|
+
* message is pinned, blocked (see below), or still missing a result.
|
|
40
|
+
* 3. Build `state`: a clone of the full message list where every candidate
|
|
41
|
+
* pair's result content is replaced with a short `ok, N chars (omitted)`
|
|
42
|
+
* note. This is the cheap representation fed to the judge, never what a
|
|
43
|
+
* KEPT item ends up looking like in the final output.
|
|
44
|
+
* 4. Fit `state` under `maxStateTokens` via STAGED truncation, applied in
|
|
45
|
+
* this exact order, stopping as soon as the ceiling is met:
|
|
46
|
+
* a. candidate tool inputs truncated to 1000, then 200, then 60 chars;
|
|
47
|
+
* b. old (non-pinned, non-blocked) text blocks abridged head+tail;
|
|
48
|
+
* c. old messages collapsed to a single placeholder;
|
|
49
|
+
* d. old tool calls reduced to one line.
|
|
50
|
+
* 5. For every candidate pair, call `asker` once with enough context
|
|
51
|
+
* (drawn from the FITTED state, not the raw original) to get back
|
|
52
|
+
* `keepCall`/`keepResult` — each a 0..1 "yes" strength, not necessarily
|
|
53
|
+
* a strict boolean, so a threshold can be tuned without changing this
|
|
54
|
+
* module.
|
|
55
|
+
* 6. Apply `keepThreshold` (default 0.5) to the ORIGINAL (verbatim)
|
|
56
|
+
* messages, never the truncated `state`:
|
|
57
|
+
* - `keepResult >= threshold` -> keep both verbatim;
|
|
58
|
+
* - else `keepCall >= threshold` -> keep the call verbatim, replace the
|
|
59
|
+
* result with the same short omitted note `state` used;
|
|
60
|
+
* - else -> drop both blocks entirely.
|
|
61
|
+
* 7. Rebuild the message list: a message left with no content blocks is
|
|
62
|
+
* removed outright; a `tool_result` whose `tool_use` was dropped is
|
|
63
|
+
* removed too (never left orphaned), even though step 6 already drops
|
|
64
|
+
* both halves of a pair together — this is a second, defensive pass.
|
|
65
|
+
*
|
|
66
|
+
* ## Token estimation
|
|
67
|
+
*
|
|
68
|
+
* A calibrated heuristic, not a real tokenizer (no new dependency): ~1
|
|
69
|
+
* token per 6 letters, 0.5 token per digit, ~1 token per other (non-
|
|
70
|
+
* whitespace) symbol. Good enough for a staged-truncation ceiling; never
|
|
71
|
+
* presented as an exact count.
|
|
72
|
+
*
|
|
73
|
+
* ## Guardrail interplay (explicit, not incidental)
|
|
74
|
+
*
|
|
75
|
+
* `isBlockedAssistantTurn` (reused verbatim from `./blocked-turns.js`, not
|
|
76
|
+
* reimplemented) flags a Jev-blocked assistant turn. Any message it flags
|
|
77
|
+
* is treated exactly like a pinned message here: never a compaction
|
|
78
|
+
* candidate, never truncated, never dropped. In the normal running system
|
|
79
|
+
* `stripBlockedTurns` already removes these turns upstream before this
|
|
80
|
+
* module ever sees them — this check exists so that if `stripBlockedTurns`
|
|
81
|
+
* is ever disabled or reordered independently, this module cannot
|
|
82
|
+
* re-introduce or corrupt a blocked turn by treating it as ordinary
|
|
83
|
+
* history to compact.
|
|
84
|
+
*/
|
|
85
|
+
import { isBlockedAssistantTurn } from "./blocked-turns.js"
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------------ types --
|
|
88
|
+
|
|
89
|
+
export type MessageRole = "system" | "user" | "assistant"
|
|
90
|
+
|
|
91
|
+
export interface TextBlock {
|
|
92
|
+
readonly type: "text"
|
|
93
|
+
readonly text: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ToolUseBlock {
|
|
97
|
+
readonly type: "tool_use"
|
|
98
|
+
readonly id: string
|
|
99
|
+
readonly name: string
|
|
100
|
+
readonly input: unknown
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ToolResultBlock {
|
|
104
|
+
readonly type: "tool_result"
|
|
105
|
+
readonly tool_use_id: string
|
|
106
|
+
readonly content: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
|
|
110
|
+
|
|
111
|
+
/** The raw host `info` object a message carries, narrowed to exactly what
|
|
112
|
+
* `isBlockedAssistantTurn` reads -- so this module stays usable with
|
|
113
|
+
* OpenCode's real `{ info, parts }` shape without importing it. */
|
|
114
|
+
export type CompactionMessageInfo = Parameters<typeof isBlockedAssistantTurn>[0]
|
|
115
|
+
|
|
116
|
+
export interface CompactionMessage {
|
|
117
|
+
readonly role: MessageRole
|
|
118
|
+
readonly content: string | readonly ContentBlock[]
|
|
119
|
+
/** Passed through unchanged; only ever read by `isBlockedAssistantTurn`. */
|
|
120
|
+
readonly info?: CompactionMessageInfo
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What the `asker` is given to answer `keepCall`/`keepResult` for one
|
|
124
|
+
* candidate tool round trip. Drawn from the FITTED `state`, never the raw
|
|
125
|
+
* original -- that is the entire point of fitting `state` under a ceiling
|
|
126
|
+
* first. */
|
|
127
|
+
export interface AskerContext {
|
|
128
|
+
readonly id: string
|
|
129
|
+
readonly toolName: string
|
|
130
|
+
readonly input: unknown
|
|
131
|
+
readonly resultSummary: string
|
|
132
|
+
/** The full fitted state, for a judge that wants broader context than
|
|
133
|
+
* just this one call/result pair. */
|
|
134
|
+
readonly state: readonly CompactionMessage[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface AskerAnswer {
|
|
138
|
+
/** 0..1 strength of "yes, keep the call around." */
|
|
139
|
+
readonly keepCall: number
|
|
140
|
+
/** 0..1 strength of "yes, keep the result around." */
|
|
141
|
+
readonly keepResult: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Transport-agnostic by design -- the caller supplies this; this module
|
|
145
|
+
* never makes a network call itself. */
|
|
146
|
+
export type Asker = (context: AskerContext) => Promise<AskerAnswer>
|
|
147
|
+
|
|
148
|
+
export interface CompactionOptions {
|
|
149
|
+
/** How many of the newest messages are always pinned, in addition to the
|
|
150
|
+
* first message and the first user message. */
|
|
151
|
+
readonly preserveRecentMessages?: number
|
|
152
|
+
/** `keepResult`/`keepCall` threshold (0..1) applied to the asker's
|
|
153
|
+
* answers. */
|
|
154
|
+
readonly keepThreshold?: number
|
|
155
|
+
/** Token-estimate ceiling `state` is fit under before any candidate is
|
|
156
|
+
* asked about. */
|
|
157
|
+
readonly maxStateTokens?: number
|
|
158
|
+
readonly asker: Asker
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface CompactionStats {
|
|
162
|
+
readonly originalTokens: number
|
|
163
|
+
/** Token estimate of the fitted `state` sent to the judge -- T9 surfaces
|
|
164
|
+
* this directly in spend-log metadata. */
|
|
165
|
+
readonly stateTokens: number
|
|
166
|
+
readonly finalTokens: number
|
|
167
|
+
/** `1 - finalTokens / originalTokens`; `0` when there was nothing to
|
|
168
|
+
* reduce or `originalTokens` was `0`. */
|
|
169
|
+
readonly reductionRatio: number
|
|
170
|
+
readonly candidateCount: number
|
|
171
|
+
readonly droppedCount: number
|
|
172
|
+
readonly truncatedCount: number
|
|
173
|
+
readonly keptCount: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface CompactionResult {
|
|
177
|
+
readonly messages: readonly CompactionMessage[]
|
|
178
|
+
readonly stats: CompactionStats
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const DEFAULT_PRESERVE_RECENT_MESSAGES = 6
|
|
182
|
+
export const DEFAULT_KEEP_THRESHOLD = 0.5
|
|
183
|
+
/** No ceiling is specified by the ported algorithm's own README; this is a
|
|
184
|
+
* conservative default a caller can override via `CompactionOptions.
|
|
185
|
+
* maxStateTokens` once real pilot data suggests a better number. */
|
|
186
|
+
export const DEFAULT_MAX_STATE_TOKENS = 12_000
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------ tokenizing --
|
|
189
|
+
|
|
190
|
+
/** Calibrated heuristic, not a real tokenizer: ~1 token per 6 letters, 0.5
|
|
191
|
+
* per digit, ~1 per other non-whitespace symbol. See module docstring. */
|
|
192
|
+
export function estimateTokens(text: string): number {
|
|
193
|
+
let tokens = 0
|
|
194
|
+
for (let i = 0; i < text.length; i++) {
|
|
195
|
+
const code = text.charCodeAt(i)
|
|
196
|
+
const isLetter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
197
|
+
const isDigit = code >= 48 && code <= 57
|
|
198
|
+
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
|
199
|
+
if (isLetter) tokens += 1 / 6
|
|
200
|
+
else if (isDigit) tokens += 0.5
|
|
201
|
+
else if (!isWhitespace) tokens += 1
|
|
202
|
+
}
|
|
203
|
+
return tokens
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function blockText(block: ContentBlock): string {
|
|
207
|
+
if (block.type === "text") return block.text
|
|
208
|
+
if (block.type === "tool_use") return `${block.name} ${typeof block.input === "string" ? block.input : JSON.stringify(block.input)}`
|
|
209
|
+
return block.content
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function messageText(message: CompactionMessage): string {
|
|
213
|
+
return typeof message.content === "string" ? message.content : message.content.map(blockText).join(" ")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function estimateMessagesTokens(messages: readonly CompactionMessage[]): number {
|
|
217
|
+
let total = 0
|
|
218
|
+
for (const message of messages) total += estimateTokens(messageText(message))
|
|
219
|
+
return total
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --------------------------------------------------------------- pairing --
|
|
223
|
+
|
|
224
|
+
interface ToolPair {
|
|
225
|
+
readonly id: string
|
|
226
|
+
readonly callMessageIndex: number
|
|
227
|
+
readonly callBlockIndex: number
|
|
228
|
+
readonly resultMessageIndex: number | null
|
|
229
|
+
readonly resultBlockIndex: number | null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function collectPairs(messages: readonly CompactionMessage[]): ToolPair[] {
|
|
233
|
+
const pairs = new Map<string, ToolPair>()
|
|
234
|
+
|
|
235
|
+
messages.forEach((message, messageIndex) => {
|
|
236
|
+
if (typeof message.content === "string") return
|
|
237
|
+
message.content.forEach((block, blockIndex) => {
|
|
238
|
+
if (block.type !== "tool_use") return
|
|
239
|
+
pairs.set(block.id, {
|
|
240
|
+
id: block.id,
|
|
241
|
+
callMessageIndex: messageIndex,
|
|
242
|
+
callBlockIndex: blockIndex,
|
|
243
|
+
resultMessageIndex: null,
|
|
244
|
+
resultBlockIndex: null,
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
messages.forEach((message, messageIndex) => {
|
|
250
|
+
if (typeof message.content === "string") return
|
|
251
|
+
message.content.forEach((block, blockIndex) => {
|
|
252
|
+
if (block.type !== "tool_result") return
|
|
253
|
+
const existing = pairs.get(block.tool_use_id)
|
|
254
|
+
if (!existing) return
|
|
255
|
+
pairs.set(block.tool_use_id, { ...existing, resultMessageIndex: messageIndex, resultBlockIndex: blockIndex })
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
return [...pairs.values()]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------- pin/guardrail --
|
|
263
|
+
|
|
264
|
+
function computePinnedIndices(messages: readonly CompactionMessage[], preserveRecentMessages: number): Set<number> {
|
|
265
|
+
const pinned = new Set<number>()
|
|
266
|
+
if (messages.length > 0) pinned.add(0)
|
|
267
|
+
|
|
268
|
+
const firstUserIndex = messages.findIndex((message) => message.role === "user")
|
|
269
|
+
if (firstUserIndex >= 0) pinned.add(firstUserIndex)
|
|
270
|
+
|
|
271
|
+
for (let i = Math.max(0, messages.length - preserveRecentMessages); i < messages.length; i++) pinned.add(i)
|
|
272
|
+
return pinned
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Messages `isBlockedAssistantTurn` flags -- reused verbatim, never a
|
|
276
|
+
* compaction candidate regardless of `preserveRecentMessages`. See module
|
|
277
|
+
* docstring's "Guardrail interplay" section. */
|
|
278
|
+
function computeBlockedIndices(messages: readonly CompactionMessage[]): Set<number> {
|
|
279
|
+
const blocked = new Set<number>()
|
|
280
|
+
messages.forEach((message, index) => {
|
|
281
|
+
if (message.role === "assistant" && isBlockedAssistantTurn(message.info)) blocked.add(index)
|
|
282
|
+
})
|
|
283
|
+
return blocked
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------- clones --
|
|
287
|
+
|
|
288
|
+
type MutableBlock = TextBlock | ToolUseBlock | ToolResultBlock
|
|
289
|
+
interface MutableMessage {
|
|
290
|
+
role: MessageRole
|
|
291
|
+
info: CompactionMessageInfo
|
|
292
|
+
content: string | MutableBlock[]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function deepCloneValue<T>(value: T): T {
|
|
296
|
+
if (value === undefined) return value
|
|
297
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cloneBlock(block: ContentBlock): MutableBlock {
|
|
301
|
+
if (block.type === "tool_use") return { type: "tool_use", id: block.id, name: block.name, input: deepCloneValue(block.input) }
|
|
302
|
+
if (block.type === "tool_result") return { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content }
|
|
303
|
+
return { type: "text", text: block.text }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function cloneMessages(messages: readonly CompactionMessage[]): MutableMessage[] {
|
|
307
|
+
return messages.map((message) => ({
|
|
308
|
+
role: message.role,
|
|
309
|
+
info: message.info,
|
|
310
|
+
content: typeof message.content === "string" ? message.content : message.content.map(cloneBlock),
|
|
311
|
+
}))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function omittedNote(length: number): string {
|
|
315
|
+
return `ok, ${length} chars (omitted)`
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ----------------------------------------------------- staged truncation --
|
|
319
|
+
|
|
320
|
+
function truncateToolInputs(state: MutableMessage[], candidateCallIndices: ReadonlySet<number>, maxChars: number): void {
|
|
321
|
+
state.forEach((message, index) => {
|
|
322
|
+
if (!candidateCallIndices.has(index) || typeof message.content === "string") return
|
|
323
|
+
message.content = message.content.map((block) => {
|
|
324
|
+
if (block.type !== "tool_use") return block
|
|
325
|
+
const serialized = typeof block.input === "string" ? block.input : JSON.stringify(block.input)
|
|
326
|
+
if (serialized.length <= maxChars) return block
|
|
327
|
+
return { ...block, input: `${serialized.slice(0, maxChars)}... (truncated)` }
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function abridgeText(text: string, headChars: number, tailChars: number): string {
|
|
333
|
+
if (text.length <= headChars + tailChars + 20) return text
|
|
334
|
+
const omitted = text.length - headChars - tailChars
|
|
335
|
+
return `${text.slice(0, headChars)}... [${omitted} chars omitted] ...${text.slice(text.length - tailChars)}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function abridgeTexts(state: MutableMessage[], nonPinnedIndices: ReadonlySet<number>): void {
|
|
339
|
+
const headChars = 200
|
|
340
|
+
const tailChars = 200
|
|
341
|
+
state.forEach((message, index) => {
|
|
342
|
+
if (!nonPinnedIndices.has(index)) return
|
|
343
|
+
if (typeof message.content === "string") {
|
|
344
|
+
message.content = abridgeText(message.content, headChars, tailChars)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
message.content = message.content.map((block) =>
|
|
348
|
+
block.type === "text" ? { ...block, text: abridgeText(block.text, headChars, tailChars) } : block,
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function collapseMessages(state: MutableMessage[], nonPinnedIndices: ReadonlySet<number>): void {
|
|
354
|
+
state.forEach((message, index) => {
|
|
355
|
+
if (!nonPinnedIndices.has(index)) return
|
|
356
|
+
message.content = "[collapsed]"
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function reduceToolCallsToOneLine(state: MutableMessage[], candidateCallIndices: ReadonlySet<number>): void {
|
|
361
|
+
state.forEach((message, index) => {
|
|
362
|
+
if (!candidateCallIndices.has(index) || typeof message.content === "string") return
|
|
363
|
+
message.content = message.content.map((block) =>
|
|
364
|
+
block.type === "tool_use" ? { ...block, input: "(call details omitted)" } : block,
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function messagesTokensOf(state: readonly MutableMessage[]): number {
|
|
370
|
+
return estimateMessagesTokens(state as readonly CompactionMessage[])
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Applies the four truncation stages, in the exact order the spec
|
|
374
|
+
* requires, stopping the moment `state` fits under `maxStateTokens`. */
|
|
375
|
+
function fitState(
|
|
376
|
+
state: MutableMessage[],
|
|
377
|
+
pinned: ReadonlySet<number>,
|
|
378
|
+
blocked: ReadonlySet<number>,
|
|
379
|
+
candidateCallIndices: ReadonlySet<number>,
|
|
380
|
+
maxStateTokens: number,
|
|
381
|
+
): void {
|
|
382
|
+
if (messagesTokensOf(state) <= maxStateTokens) return
|
|
383
|
+
|
|
384
|
+
const nonPinned = new Set<number>()
|
|
385
|
+
state.forEach((_message, index) => {
|
|
386
|
+
if (!pinned.has(index) && !blocked.has(index)) nonPinned.add(index)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
for (const maxChars of [1000, 200, 60]) {
|
|
390
|
+
truncateToolInputs(state, candidateCallIndices, maxChars)
|
|
391
|
+
if (messagesTokensOf(state) <= maxStateTokens) return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
abridgeTexts(state, nonPinned)
|
|
395
|
+
if (messagesTokensOf(state) <= maxStateTokens) return
|
|
396
|
+
|
|
397
|
+
collapseMessages(state, nonPinned)
|
|
398
|
+
if (messagesTokensOf(state) <= maxStateTokens) return
|
|
399
|
+
|
|
400
|
+
reduceToolCallsToOneLine(state, candidateCallIndices)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --------------------------------------------------------------- rebuild --
|
|
404
|
+
|
|
405
|
+
function zeroStats(originalTokens: number): CompactionStats {
|
|
406
|
+
return {
|
|
407
|
+
originalTokens,
|
|
408
|
+
stateTokens: originalTokens,
|
|
409
|
+
finalTokens: originalTokens,
|
|
410
|
+
reductionRatio: 0,
|
|
411
|
+
candidateCount: 0,
|
|
412
|
+
droppedCount: 0,
|
|
413
|
+
truncatedCount: 0,
|
|
414
|
+
keptCount: 0,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function removeOrphanToolResults(messages: readonly CompactionMessage[]): CompactionMessage[] {
|
|
419
|
+
const callIds = new Set<string>()
|
|
420
|
+
for (const message of messages) {
|
|
421
|
+
if (typeof message.content === "string") continue
|
|
422
|
+
for (const block of message.content) if (block.type === "tool_use") callIds.add(block.id)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const cleaned: CompactionMessage[] = []
|
|
426
|
+
for (const message of messages) {
|
|
427
|
+
if (typeof message.content === "string") {
|
|
428
|
+
cleaned.push(message)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
const content = message.content.filter((block) => block.type !== "tool_result" || callIds.has(block.tool_use_id))
|
|
432
|
+
if (content.length === 0) continue
|
|
433
|
+
cleaned.push({ role: message.role, info: message.info, content })
|
|
434
|
+
}
|
|
435
|
+
return cleaned
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function rebuildMessages(
|
|
439
|
+
messages: readonly CompactionMessage[],
|
|
440
|
+
dropIds: ReadonlySet<string>,
|
|
441
|
+
truncateResultIds: ReadonlySet<string>,
|
|
442
|
+
pairsById: ReadonlyMap<string, ToolPair>,
|
|
443
|
+
): CompactionMessage[] {
|
|
444
|
+
const working: { role: MessageRole; info: CompactionMessageInfo; content: string | (ContentBlock | null)[] }[] =
|
|
445
|
+
messages.map((message) => ({
|
|
446
|
+
role: message.role,
|
|
447
|
+
info: message.info,
|
|
448
|
+
content: typeof message.content === "string" ? message.content : [...message.content],
|
|
449
|
+
}))
|
|
450
|
+
|
|
451
|
+
const setNull = (messageIndex: number, blockIndex: number): void => {
|
|
452
|
+
const content = working[messageIndex]?.content
|
|
453
|
+
if (!content || typeof content === "string") return
|
|
454
|
+
content[blockIndex] = null
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const id of dropIds) {
|
|
458
|
+
const pair = pairsById.get(id)
|
|
459
|
+
if (!pair) continue
|
|
460
|
+
setNull(pair.callMessageIndex, pair.callBlockIndex)
|
|
461
|
+
if (pair.resultMessageIndex !== null && pair.resultBlockIndex !== null) {
|
|
462
|
+
setNull(pair.resultMessageIndex, pair.resultBlockIndex)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const id of truncateResultIds) {
|
|
467
|
+
const pair = pairsById.get(id)
|
|
468
|
+
if (!pair || pair.resultMessageIndex === null || pair.resultBlockIndex === null) continue
|
|
469
|
+
const content = working[pair.resultMessageIndex]?.content
|
|
470
|
+
if (!content || typeof content === "string") continue
|
|
471
|
+
const block = content[pair.resultBlockIndex]
|
|
472
|
+
if (block && block.type === "tool_result") {
|
|
473
|
+
content[pair.resultBlockIndex] = { type: "tool_result", tool_use_id: block.tool_use_id, content: omittedNote(block.content.length) }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const rebuilt: CompactionMessage[] = []
|
|
478
|
+
for (const message of working) {
|
|
479
|
+
const content = typeof message.content === "string" ? message.content : message.content.filter((b): b is ContentBlock => b !== null)
|
|
480
|
+
if (typeof content !== "string" && content.length === 0) continue
|
|
481
|
+
rebuilt.push({ role: message.role, info: message.info, content })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return removeOrphanToolResults(rebuilt)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function findBlock<T extends ContentBlock["type"]>(
|
|
488
|
+
state: readonly MutableMessage[],
|
|
489
|
+
messageIndex: number,
|
|
490
|
+
blockIndex: number,
|
|
491
|
+
type: T,
|
|
492
|
+
): Extract<ContentBlock, { type: T }> | undefined {
|
|
493
|
+
const message = state[messageIndex]
|
|
494
|
+
if (!message || typeof message.content === "string") return undefined
|
|
495
|
+
const block = message.content[blockIndex]
|
|
496
|
+
if (!block || block.type !== type) return undefined
|
|
497
|
+
return block as Extract<ContentBlock, { type: T }>
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ------------------------------------------------------------------ main --
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Runs the Fast Jev Compaction algorithm over `messages`. See module
|
|
504
|
+
* docstring for the full step-by-step description. Never mutates the input
|
|
505
|
+
* array or its messages.
|
|
506
|
+
*/
|
|
507
|
+
export async function compactMessages(
|
|
508
|
+
messages: readonly CompactionMessage[],
|
|
509
|
+
options: CompactionOptions,
|
|
510
|
+
): Promise<CompactionResult> {
|
|
511
|
+
const preserveRecentMessages = options.preserveRecentMessages ?? DEFAULT_PRESERVE_RECENT_MESSAGES
|
|
512
|
+
const keepThreshold = options.keepThreshold ?? DEFAULT_KEEP_THRESHOLD
|
|
513
|
+
const maxStateTokens = options.maxStateTokens ?? DEFAULT_MAX_STATE_TOKENS
|
|
514
|
+
const { asker } = options
|
|
515
|
+
|
|
516
|
+
const originalTokens = estimateMessagesTokens(messages)
|
|
517
|
+
if (messages.length === 0) {
|
|
518
|
+
return { messages: [], stats: zeroStats(originalTokens) }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const pinned = computePinnedIndices(messages, preserveRecentMessages)
|
|
522
|
+
const blocked = computeBlockedIndices(messages)
|
|
523
|
+
const allPairs = collectPairs(messages)
|
|
524
|
+
|
|
525
|
+
const candidates = allPairs.filter((pair) => {
|
|
526
|
+
if (pair.resultMessageIndex === null) return false
|
|
527
|
+
if (pinned.has(pair.callMessageIndex) || blocked.has(pair.callMessageIndex)) return false
|
|
528
|
+
if (pinned.has(pair.resultMessageIndex) || blocked.has(pair.resultMessageIndex)) return false
|
|
529
|
+
return true
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
if (candidates.length === 0) {
|
|
533
|
+
return { messages: [...messages], stats: zeroStats(originalTokens) }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const candidateCallIndices = new Set(candidates.map((pair) => pair.callMessageIndex))
|
|
537
|
+
const pairsById = new Map(candidates.map((pair) => [pair.id, pair] as const))
|
|
538
|
+
|
|
539
|
+
const state = cloneMessages(messages)
|
|
540
|
+
for (const pair of candidates) {
|
|
541
|
+
if (pair.resultMessageIndex === null || pair.resultBlockIndex === null) continue
|
|
542
|
+
const message = state[pair.resultMessageIndex]
|
|
543
|
+
if (!message || typeof message.content === "string") continue
|
|
544
|
+
const block = message.content[pair.resultBlockIndex]
|
|
545
|
+
if (!block || block.type !== "tool_result") continue
|
|
546
|
+
message.content[pair.resultBlockIndex] = { type: "tool_result", tool_use_id: block.tool_use_id, content: omittedNote(block.content.length) }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
fitState(state, pinned, blocked, candidateCallIndices, maxStateTokens)
|
|
550
|
+
const stateTokens = messagesTokensOf(state)
|
|
551
|
+
|
|
552
|
+
let droppedCount = 0
|
|
553
|
+
let truncatedCount = 0
|
|
554
|
+
let keptCount = 0
|
|
555
|
+
const dropIds = new Set<string>()
|
|
556
|
+
const truncateResultIds = new Set<string>()
|
|
557
|
+
|
|
558
|
+
for (const pair of candidates) {
|
|
559
|
+
const callBlock = findBlock(state, pair.callMessageIndex, pair.callBlockIndex, "tool_use")
|
|
560
|
+
const resultBlock =
|
|
561
|
+
pair.resultMessageIndex !== null && pair.resultBlockIndex !== null
|
|
562
|
+
? findBlock(state, pair.resultMessageIndex, pair.resultBlockIndex, "tool_result")
|
|
563
|
+
: undefined
|
|
564
|
+
|
|
565
|
+
const answer = await asker({
|
|
566
|
+
id: pair.id,
|
|
567
|
+
toolName: callBlock?.name ?? "unknown",
|
|
568
|
+
input: callBlock?.input,
|
|
569
|
+
resultSummary: resultBlock?.content ?? "",
|
|
570
|
+
state: state as readonly CompactionMessage[],
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
if (answer.keepResult >= keepThreshold) {
|
|
574
|
+
keptCount += 1
|
|
575
|
+
} else if (answer.keepCall >= keepThreshold) {
|
|
576
|
+
truncateResultIds.add(pair.id)
|
|
577
|
+
truncatedCount += 1
|
|
578
|
+
} else {
|
|
579
|
+
dropIds.add(pair.id)
|
|
580
|
+
droppedCount += 1
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const finalMessages = rebuildMessages(messages, dropIds, truncateResultIds, pairsById)
|
|
585
|
+
const finalTokens = estimateMessagesTokens(finalMessages)
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
messages: finalMessages,
|
|
589
|
+
stats: {
|
|
590
|
+
originalTokens,
|
|
591
|
+
stateTokens,
|
|
592
|
+
finalTokens,
|
|
593
|
+
reductionRatio: originalTokens > 0 ? 1 - finalTokens / originalTokens : 0,
|
|
594
|
+
candidateCount: candidates.length,
|
|
595
|
+
droppedCount,
|
|
596
|
+
truncatedCount,
|
|
597
|
+
keptCount,
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
}
|
|
@@ -99,6 +99,10 @@ interface CacheEntry {
|
|
|
99
99
|
jevPolicyEnabled: boolean
|
|
100
100
|
/** Jev audit (2026-10, G2). */
|
|
101
101
|
jevOutputValidationEnabled: boolean
|
|
102
|
+
/** Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
|
|
103
|
+
* compaction-pilot.md`, task T7) -- `Team.eai_compaction_enabled`, read
|
|
104
|
+
* from this SAME `GET /teams/{id}` response, never a second round trip. */
|
|
105
|
+
compactionEnabled: boolean
|
|
102
106
|
teamId: string
|
|
103
107
|
expiresAt: number
|
|
104
108
|
}
|
|
@@ -159,10 +163,14 @@ export interface LiveCheckDeps {
|
|
|
159
163
|
* all). Returns `null` under the exact same conditions `liveJevPolicyEnabled`
|
|
160
164
|
* always has (see its own docstring, preserved below) — no team context, an
|
|
161
165
|
* instance/request team-id mismatch, or a failed check with no usable cache.
|
|
166
|
+
*
|
|
167
|
+
* Fast Jev Compaction pilot (T7): also reads `eai_compaction_enabled` from
|
|
168
|
+
* this same response/cache entry -- one more field off a call this module
|
|
169
|
+
* already makes, not a second network round trip.
|
|
162
170
|
*/
|
|
163
171
|
async function liveJevGuardrailFlags(
|
|
164
172
|
deps: LiveCheckDeps = {},
|
|
165
|
-
): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean } | null> {
|
|
173
|
+
): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean; compactionEnabled: boolean } | null> {
|
|
166
174
|
const now = deps.now ?? Date.now
|
|
167
175
|
const fetchImpl = deps.fetchImpl ?? fetch
|
|
168
176
|
const readConfig = deps.readConfig ?? readElliotConfig
|
|
@@ -191,7 +199,11 @@ async function liveJevGuardrailFlags(
|
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
if (cache && cache.teamId === config.team_id && now() < cache.expiresAt) {
|
|
194
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
jevPolicyEnabled: cache.jevPolicyEnabled,
|
|
204
|
+
jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
|
|
205
|
+
compactionEnabled: cache.compactionEnabled,
|
|
206
|
+
}
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
const controller = new AbortController()
|
|
@@ -207,13 +219,15 @@ async function liveJevGuardrailFlags(
|
|
|
207
219
|
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {}
|
|
208
220
|
const jevPolicyEnabled = Boolean(record.jev_policy_enabled)
|
|
209
221
|
const jevOutputValidationEnabled = Boolean(record.jev_output_validation_enabled)
|
|
222
|
+
const compactionEnabled = Boolean(record.eai_compaction_enabled)
|
|
210
223
|
cache = {
|
|
211
224
|
jevPolicyEnabled,
|
|
212
225
|
jevOutputValidationEnabled,
|
|
226
|
+
compactionEnabled,
|
|
213
227
|
teamId: config.team_id,
|
|
214
228
|
expiresAt: now() + CACHE_TTL_MS,
|
|
215
229
|
}
|
|
216
|
-
return { jevPolicyEnabled, jevOutputValidationEnabled }
|
|
230
|
+
return { jevPolicyEnabled, jevOutputValidationEnabled, compactionEnabled }
|
|
217
231
|
} catch {
|
|
218
232
|
// Offline, backend unreachable, expired token, or a malformed response.
|
|
219
233
|
// Never throw into the chat.params hook over this -- a CLI message must
|
|
@@ -221,7 +235,11 @@ async function liveJevGuardrailFlags(
|
|
|
221
235
|
// to a still-fresh cached value for this same team if one exists;
|
|
222
236
|
// otherwise genuinely unknown.
|
|
223
237
|
return cache && cache.teamId === config.team_id && now() < cache.expiresAt
|
|
224
|
-
? {
|
|
238
|
+
? {
|
|
239
|
+
jevPolicyEnabled: cache.jevPolicyEnabled,
|
|
240
|
+
jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
|
|
241
|
+
compactionEnabled: cache.compactionEnabled,
|
|
242
|
+
}
|
|
225
243
|
: null
|
|
226
244
|
} finally {
|
|
227
245
|
clearTimeout(timer)
|
|
@@ -253,6 +271,28 @@ export async function liveJevOutputValidationEnabled(deps: LiveCheckDeps = {}):
|
|
|
253
271
|
return flags ? flags.jevOutputValidationEnabled : null
|
|
254
272
|
}
|
|
255
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
|
|
276
|
+
* pilot.md`, task T7) — the team's current `Team.eai_compaction_enabled`,
|
|
277
|
+
* live from the backend, with the exact same `null`-means-"leave it alone"
|
|
278
|
+
* contract as `liveJevPolicyEnabled`/`liveJevOutputValidationEnabled`.
|
|
279
|
+
* Shares the same cached `GET /teams/{id}` call under the hood, so calling
|
|
280
|
+
* this alongside either (or both) of the other two costs at most one
|
|
281
|
+
* network round trip, not two or three.
|
|
282
|
+
*
|
|
283
|
+
* Callers (index.ts's `experimental.chat.messages.transform` hook) must
|
|
284
|
+
* treat `null` and `false` identically here: skip compaction and leave
|
|
285
|
+
* `output.messages` exactly as `stripBlockedTurns` left them. Unlike the
|
|
286
|
+
* Jev guardrails, there is no security-load-bearing reason to fail closed
|
|
287
|
+
* on uncertainty -- this flag only decides whether a token-reduction pass
|
|
288
|
+
* runs at all, so "do nothing" is always the safe default when the answer
|
|
289
|
+
* is not confidently known.
|
|
290
|
+
*/
|
|
291
|
+
export async function liveCompactionEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
|
|
292
|
+
const flags = await liveJevGuardrailFlags(deps)
|
|
293
|
+
return flags ? flags.compactionEnabled : null
|
|
294
|
+
}
|
|
295
|
+
|
|
256
296
|
/**
|
|
257
297
|
* Applies a live guardrail decision to the outgoing request's `options`,
|
|
258
298
|
* mutating `metadata.guardrails` to match exactly — adding `guardrailName`
|
|
@@ -27,12 +27,22 @@ import { resolveEndUserId, type SessionClient } from "./identity-headers.js"
|
|
|
27
27
|
import {
|
|
28
28
|
applyGuardrailDecision,
|
|
29
29
|
extractInstanceTeamId,
|
|
30
|
+
liveCompactionEnabled,
|
|
30
31
|
liveJevOutputValidationEnabled,
|
|
31
32
|
liveJevPolicyEnabled,
|
|
32
33
|
OUTPUT_VALIDATION_GUARDRAIL_NAME,
|
|
33
34
|
} from "./guardrail-sync.js"
|
|
34
|
-
import { stampSessionAffinity } from "./session-affinity.js"
|
|
35
|
+
import { stampSessionAffinity, type CompactionTelemetry } from "./session-affinity.js"
|
|
35
36
|
import { stripBlockedTurns } from "./blocked-turns.js"
|
|
37
|
+
import { readElliotConfig } from "./elliot-auth.js"
|
|
38
|
+
import {
|
|
39
|
+
compactMessages,
|
|
40
|
+
type AskerAnswer,
|
|
41
|
+
type AskerContext,
|
|
42
|
+
type CompactionMessage,
|
|
43
|
+
type ContentBlock,
|
|
44
|
+
type MessageRole,
|
|
45
|
+
} from "./compaction.js"
|
|
36
46
|
|
|
37
47
|
// Structural mirrors of the host types (packages/plugin/src/index.ts @
|
|
38
48
|
// v1.18.19). Declared locally so this package type-checks and unit-tests
|
|
@@ -125,6 +135,256 @@ export interface Hooks {
|
|
|
125
135
|
|
|
126
136
|
export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
|
|
127
137
|
|
|
138
|
+
// ---------------------------------------------------- compaction adapter --
|
|
139
|
+
//
|
|
140
|
+
// Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
|
|
141
|
+
// pilot.md`, task T7). `compaction.ts` (T6) is deliberately transport- and
|
|
142
|
+
// host-shape-agnostic: it only knows its own `CompactionMessage`/
|
|
143
|
+
// `ContentBlock` shape. Everything below adapts OpenCode's native history
|
|
144
|
+
// entry shape (`{ info, parts }`, the same one `blocked-turns.ts` reads) to
|
|
145
|
+
// and from that shape, and supplies the `asker` that calls the T3 backend
|
|
146
|
+
// proxy. None of this runs unless `liveCompactionEnabled` resolves `true`,
|
|
147
|
+
// and the whole attempt is wrapped in a single try/catch in the hook below
|
|
148
|
+
// so a bad response, a network failure, or a bug in this adapter can never
|
|
149
|
+
// break or delay a chat turn -- it only ever falls back to whatever
|
|
150
|
+
// `stripBlockedTurns` already produced.
|
|
151
|
+
|
|
152
|
+
/** Per-request timeout for one `keepCall`/`keepResult` judge call. Several
|
|
153
|
+
* of these may run per turn (one per candidate tool round trip); kept
|
|
154
|
+
* short so a hung backend cannot meaningfully delay a chat turn even
|
|
155
|
+
* before the outer try/catch gives up on compaction entirely. */
|
|
156
|
+
const COMPACTION_ASKER_TIMEOUT_MS = 5_000
|
|
157
|
+
|
|
158
|
+
interface NativeToolPartState {
|
|
159
|
+
status?: unknown
|
|
160
|
+
input?: unknown
|
|
161
|
+
output?: unknown
|
|
162
|
+
error?: unknown
|
|
163
|
+
[k: string]: unknown
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface NativeToolPart {
|
|
167
|
+
type: "tool"
|
|
168
|
+
tool?: unknown
|
|
169
|
+
callID?: unknown
|
|
170
|
+
state?: NativeToolPartState
|
|
171
|
+
[k: string]: unknown
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type NativePart = { type?: unknown; text?: unknown } & Record<string, unknown>
|
|
175
|
+
|
|
176
|
+
interface NativeMessage {
|
|
177
|
+
info?: Record<string, unknown>
|
|
178
|
+
parts?: unknown[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nativeMessageRole(info: Record<string, unknown> | undefined): MessageRole {
|
|
182
|
+
if (info?.["role"] === "assistant") return "assistant"
|
|
183
|
+
if (info?.["role"] === "system") return "system"
|
|
184
|
+
return "user"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface CompactionAdapterState {
|
|
188
|
+
readonly compactionMessages: CompactionMessage[]
|
|
189
|
+
/** Original native tool part per `callID`, used on the way back to
|
|
190
|
+
* restore everything compaction never needed to know about (metadata,
|
|
191
|
+
* attachments, timing) rather than rebuilding it from scratch. */
|
|
192
|
+
readonly toolPartsByCallId: Map<string, NativeToolPart>
|
|
193
|
+
/** Parts compaction has no vocabulary for (reasoning, step-start, etc.),
|
|
194
|
+
* keyed by the message's own `info` object reference -- `compaction.ts`
|
|
195
|
+
* carries `info` through unchanged on every surviving `CompactionMessage`
|
|
196
|
+
* (see its own module docstring), so looking it back up by identity is
|
|
197
|
+
* reliable regardless of what compaction dropped or kept. */
|
|
198
|
+
readonly passthroughPartsByInfo: Map<object, unknown[]>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Converts OpenCode's native `{ info, parts }` history entries into T6's
|
|
202
|
+
* `CompactionMessage` shape. A native "tool" part (OpenCode pairs the call
|
|
203
|
+
* and its result into ONE part, unlike Claude-style `tool_use`/
|
|
204
|
+
* `tool_result` content blocks) becomes a `tool_use` block immediately
|
|
205
|
+
* followed, in the SAME message, by a `tool_result` block once the call has
|
|
206
|
+
* actually completed or errored -- `compaction.ts`'s pairing logic matches
|
|
207
|
+
* blocks by id regardless of which message(s) they live in, so this is a
|
|
208
|
+
* faithful, simpler-than-splitting-messages encoding. */
|
|
209
|
+
function toCompactionMessages(nativeMessages: readonly NativeMessage[]): CompactionAdapterState {
|
|
210
|
+
const compactionMessages: CompactionMessage[] = []
|
|
211
|
+
const toolPartsByCallId = new Map<string, NativeToolPart>()
|
|
212
|
+
const passthroughPartsByInfo = new Map<object, unknown[]>()
|
|
213
|
+
|
|
214
|
+
for (const native of nativeMessages) {
|
|
215
|
+
const info = native.info
|
|
216
|
+
const role = nativeMessageRole(info)
|
|
217
|
+
const blocks: ContentBlock[] = []
|
|
218
|
+
const passthrough: unknown[] = []
|
|
219
|
+
|
|
220
|
+
for (const rawPart of native.parts ?? []) {
|
|
221
|
+
const part = rawPart as NativePart
|
|
222
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
223
|
+
blocks.push({ type: "text", text: part.text })
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (part.type === "tool" && typeof part.callID === "string") {
|
|
227
|
+
const toolPart = part as NativeToolPart
|
|
228
|
+
const callId = part.callID
|
|
229
|
+
toolPartsByCallId.set(callId, toolPart)
|
|
230
|
+
blocks.push({
|
|
231
|
+
type: "tool_use",
|
|
232
|
+
id: callId,
|
|
233
|
+
name: typeof toolPart.tool === "string" ? toolPart.tool : "unknown",
|
|
234
|
+
input: toolPart.state?.input,
|
|
235
|
+
})
|
|
236
|
+
const status = toolPart.state?.status
|
|
237
|
+
if (status === "completed" || status === "error") {
|
|
238
|
+
const output = toolPart.state?.output
|
|
239
|
+
const errorValue = toolPart.state?.error
|
|
240
|
+
const content =
|
|
241
|
+
typeof output === "string"
|
|
242
|
+
? output
|
|
243
|
+
: typeof errorValue === "string"
|
|
244
|
+
? errorValue
|
|
245
|
+
: JSON.stringify(output ?? errorValue ?? "")
|
|
246
|
+
blocks.push({ type: "tool_result", tool_use_id: callId, content })
|
|
247
|
+
}
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
passthrough.push(rawPart)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (passthrough.length > 0 && info) passthroughPartsByInfo.set(info, passthrough)
|
|
254
|
+
compactionMessages.push({ role, content: blocks, info: info as CompactionMessage["info"] })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { compactionMessages, toolPartsByCallId, passthroughPartsByInfo }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Reverses `toCompactionMessages`, rebuilding native `{ info, parts }`
|
|
261
|
+
* entries from T6's output. A kept pair's native tool part is reused
|
|
262
|
+
* verbatim (preserving fields compaction never touched); a truncated
|
|
263
|
+
* pair's part is cloned with only `state.output` replaced by the short
|
|
264
|
+
* "omitted" note `compaction.ts` already produced; a dropped pair simply
|
|
265
|
+
* never gets a part emitted. */
|
|
266
|
+
function fromCompactionMessages(
|
|
267
|
+
compactionMessages: readonly CompactionMessage[],
|
|
268
|
+
toolPartsByCallId: ReadonlyMap<string, NativeToolPart>,
|
|
269
|
+
passthroughPartsByInfo: ReadonlyMap<object, unknown[]>,
|
|
270
|
+
): NativeMessage[] {
|
|
271
|
+
return compactionMessages.map((message) => {
|
|
272
|
+
const parts: unknown[] = []
|
|
273
|
+
|
|
274
|
+
if (typeof message.content === "string") {
|
|
275
|
+
if (message.content) parts.push({ type: "text", text: message.content })
|
|
276
|
+
} else {
|
|
277
|
+
for (const block of message.content) {
|
|
278
|
+
if (block.type === "text") {
|
|
279
|
+
parts.push({ type: "text", text: block.text })
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
if (block.type === "tool_use") {
|
|
283
|
+
const original = toolPartsByCallId.get(block.id)
|
|
284
|
+
if (original) {
|
|
285
|
+
parts.push({
|
|
286
|
+
...original,
|
|
287
|
+
state: original.state ? { ...original.state, input: block.input } : original.state,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (block.type === "tool_result") {
|
|
293
|
+
// Merge into the tool part this same loop just pushed for the
|
|
294
|
+
// matching `tool_use` -- `compaction.ts` always keeps a call's
|
|
295
|
+
// block immediately before its result's within one message (see
|
|
296
|
+
// `toCompactionMessages`), so it is always the last entry here.
|
|
297
|
+
const lastIndex = parts.length - 1
|
|
298
|
+
const last = parts[lastIndex] as NativeToolPart | undefined
|
|
299
|
+
if (last && last.type === "tool" && last.callID === block.tool_use_id) {
|
|
300
|
+
parts[lastIndex] = { ...last, state: { ...last.state, output: block.content } }
|
|
301
|
+
}
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const passthrough = message.info ? passthroughPartsByInfo.get(message.info) : undefined
|
|
308
|
+
if (passthrough) parts.push(...passthrough)
|
|
309
|
+
|
|
310
|
+
return { info: message.info as Record<string, unknown> | undefined, parts }
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The T3 proxy's own request/response contract, independent of whatever
|
|
316
|
+
* OpenRouter's Decisions API itself returns -- T3 treats both `state` and
|
|
317
|
+
* `questions` as opaque and passes `body` straight through, so this plugin
|
|
318
|
+
* (the only side that understands the payload) defines the convention: one
|
|
319
|
+
* question per `asker` call, answered as `{ answers: [{ keepCall,
|
|
320
|
+
* keepResult }] }`. Any other shape is treated as "could not get an answer"
|
|
321
|
+
* and throws, which the outer try/catch in the hook below turns into
|
|
322
|
+
* "skip compaction entirely for this turn" -- never a partial, wrongly-
|
|
323
|
+
* judged compaction.
|
|
324
|
+
*/
|
|
325
|
+
function parseDecisionAnswer(body: unknown): AskerAnswer {
|
|
326
|
+
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : null
|
|
327
|
+
const answers = record && Array.isArray(record["answers"]) ? record["answers"] : null
|
|
328
|
+
const first = answers && answers.length > 0 ? answers[0] : null
|
|
329
|
+
const firstRecord = first && typeof first === "object" ? (first as Record<string, unknown>) : null
|
|
330
|
+
const keepCall = firstRecord ? Number(firstRecord["keepCall"]) : NaN
|
|
331
|
+
const keepResult = firstRecord ? Number(firstRecord["keepResult"]) : NaN
|
|
332
|
+
if (!Number.isFinite(keepCall) || !Number.isFinite(keepResult)) {
|
|
333
|
+
throw new Error("compaction decisions response did not contain a usable answer")
|
|
334
|
+
}
|
|
335
|
+
return { keepCall, keepResult }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Builds the `Asker` the T7 wiring passes to `compactMessages`: one POST to
|
|
339
|
+
* the T3 endpoint (`POST /opencode/teams/{team_id}/compaction/decisions`)
|
|
340
|
+
* per candidate tool round trip, reusing the exact fetch/auth pattern
|
|
341
|
+
* `guardrail-sync.ts` already uses for its own backend call (bearer JWT,
|
|
342
|
+
* `~/.elliot/config.json`'s `backend_url`/`team_id`, an `AbortController`-
|
|
343
|
+
* based timeout). Throws on ANY failure -- offline, non-2xx, `available:
|
|
344
|
+
* false`, or an unparseable body -- by design: the caller (the hook below)
|
|
345
|
+
* wraps the whole compaction attempt in one try/catch, so a single bad
|
|
346
|
+
* answer here safely aborts compaction for this turn rather than risking a
|
|
347
|
+
* wrongly-judged partial result. */
|
|
348
|
+
function makeCompactionAsker(config: {
|
|
349
|
+
backendUrl: string
|
|
350
|
+
teamId: string
|
|
351
|
+
jwtToken: string
|
|
352
|
+
fetchImpl?: typeof fetch
|
|
353
|
+
}): (context: AskerContext) => Promise<AskerAnswer> {
|
|
354
|
+
const fetchImpl = config.fetchImpl ?? fetch
|
|
355
|
+
const base = config.backendUrl.replace(/\/+$/, "")
|
|
356
|
+
|
|
357
|
+
return async (context: AskerContext): Promise<AskerAnswer> => {
|
|
358
|
+
const controller = new AbortController()
|
|
359
|
+
const timer = setTimeout(() => controller.abort(), COMPACTION_ASKER_TIMEOUT_MS)
|
|
360
|
+
try {
|
|
361
|
+
const res = await fetchImpl(`${base}/opencode/teams/${encodeURIComponent(config.teamId)}/compaction/decisions`, {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: {
|
|
364
|
+
Authorization: `Bearer ${config.jwtToken}`,
|
|
365
|
+
"Content-Type": "application/json",
|
|
366
|
+
},
|
|
367
|
+
body: JSON.stringify({
|
|
368
|
+
state: context.state,
|
|
369
|
+
questions: [
|
|
370
|
+
{ id: context.id, toolName: context.toolName, input: context.input, resultSummary: context.resultSummary },
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
signal: controller.signal,
|
|
374
|
+
})
|
|
375
|
+
if (!res.ok) throw new Error(`backend returned ${res.status}`)
|
|
376
|
+
const payload: unknown = await res.json()
|
|
377
|
+
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}
|
|
378
|
+
if (record["available"] !== true) {
|
|
379
|
+
throw new Error(typeof record["reason"] === "string" ? record["reason"] : "compaction decisions unavailable")
|
|
380
|
+
}
|
|
381
|
+
return parseDecisionAnswer(record["body"])
|
|
382
|
+
} finally {
|
|
383
|
+
clearTimeout(timer)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
128
388
|
export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
129
389
|
// Fail loudly at load on bad options — a plugin that limps with a typo'd
|
|
130
390
|
// config is worse than one that says why it refused to start.
|
|
@@ -134,6 +394,47 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
134
394
|
// the one source the model cannot influence.
|
|
135
395
|
const worktree = input.worktree || input.directory
|
|
136
396
|
|
|
397
|
+
// Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
|
|
398
|
+
// pilot.md`, task T9). `experimental.chat.messages.transform` is triggered
|
|
399
|
+
// with an EMPTY `hookInput` (session/prompt.ts), so the compaction outcome
|
|
400
|
+
// it produces can't be correlated to a session id from inside that hook.
|
|
401
|
+
// `chat.params` -- which DOES get `hookInput.sessionID` and already stamps
|
|
402
|
+
// `metadata.session_id` via `stampSessionAffinity` -- fires immediately
|
|
403
|
+
// after, for the SAME turn (session/llm/request.ts builds `chat.params`
|
|
404
|
+
// from the same `msgs` the transform hook just finished mutating).
|
|
405
|
+
//
|
|
406
|
+
// A FIFO QUEUE, not a single mutable slot (code-review finding, post-T9):
|
|
407
|
+
// this plugin instance is a process-wide singleton, and both hooks are
|
|
408
|
+
// `async` -- if two sessions' turns are ever in flight concurrently (very
|
|
409
|
+
// plausible once a turn's compaction attempt awaits a live-flag check plus
|
|
410
|
+
// one network round trip per stale tool-call candidate), a single shared
|
|
411
|
+
// slot lets a slower session A's `transform` overwrite what a faster
|
|
412
|
+
// session B's `transform` just set, before B's own `chat.params` reads it
|
|
413
|
+
// -- silently mislabeling B's `LiteLLM_SpendLogs` row with A's telemetry
|
|
414
|
+
// (or vice versa), which directly undermines the before/after cost
|
|
415
|
+
// comparison T9 exists for. A FIFO queue fixes this without needing a
|
|
416
|
+
// session id in the transform hook at all: each session's own
|
|
417
|
+
// `transform`-then-`params` pair is internally ordered (params always
|
|
418
|
+
// fires after that same turn's transform), so across interleaved
|
|
419
|
+
// sessions the order in which `transform` calls finish and push still
|
|
420
|
+
// matches the order in which the corresponding `chat.params` calls fire
|
|
421
|
+
// and shift -- unlike a single slot, this doesn't require the two calls
|
|
422
|
+
// to be adjacent in time, only in queue order.
|
|
423
|
+
const pendingCompactionTelemetryQueue: CompactionTelemetry[] = []
|
|
424
|
+
|
|
425
|
+
// Wall-clock budget for the ENTIRE compaction attempt (code-review
|
|
426
|
+
// finding, post-T7): `compactMessages` awaits one `asker` call per
|
|
427
|
+
// non-pinned tool-call candidate, sequentially, each already bounded by
|
|
428
|
+
// `COMPACTION_ASKER_TIMEOUT_MS` -- but nothing previously bounded the
|
|
429
|
+
// SUM across many candidates. A turn with N stale candidates could add up
|
|
430
|
+
// to N * COMPACTION_ASKER_TIMEOUT_MS of latency before the outer
|
|
431
|
+
// try/catch below ever got a chance to fail open (that catch only fires
|
|
432
|
+
// on a thrown error, not on cumulative slowness), undercutting "this
|
|
433
|
+
// feature must never be able to break or delay a chat turn." This budget
|
|
434
|
+
// is enforced by racing the whole `compactMessages` call against a timer
|
|
435
|
+
// below, not by changing `compactMessages` itself.
|
|
436
|
+
const COMPACTION_TURN_BUDGET_MS = 15_000
|
|
437
|
+
|
|
137
438
|
return {
|
|
138
439
|
/**
|
|
139
440
|
* One handler, two jobs — a JS object literal may carry only one
|
|
@@ -216,9 +517,81 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
216
517
|
*/
|
|
217
518
|
"experimental.chat.messages.transform": async (_hookInput, output) => {
|
|
218
519
|
const filtered = stripBlockedTurns(output.messages)
|
|
219
|
-
if (filtered
|
|
220
|
-
|
|
221
|
-
|
|
520
|
+
if (filtered !== output.messages) {
|
|
521
|
+
output.messages.length = 0
|
|
522
|
+
output.messages.push(...filtered)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
|
|
526
|
+
// compaction-pilot.md`, task T7). Runs strictly AFTER `stripBlockedTurns`
|
|
527
|
+
// above -- compaction must never see a message that was already known
|
|
528
|
+
// to be a blocked turn `stripBlockedTurns` would otherwise have
|
|
529
|
+
// removed. Opt-in (`liveCompactionEnabled`) and entirely fail-open: a
|
|
530
|
+
// `null`/`false` decision, or ANY error/timeout from the live check,
|
|
531
|
+
// the adapter, or the backend call the `asker` makes, leaves
|
|
532
|
+
// `output.messages` exactly as `stripBlockedTurns` left them above --
|
|
533
|
+
// this feature must never be able to break or delay a chat turn,
|
|
534
|
+
// mirroring guardrail-sync.ts's own fail-open posture (see its module
|
|
535
|
+
// docstring) rather than the Jev guardrails' own fail-closed one,
|
|
536
|
+
// since this flag only decides whether a token-reduction pass runs at
|
|
537
|
+
// all, not a security boundary.
|
|
538
|
+
// This turn's own outcome, pushed onto the FIFO queue (see its
|
|
539
|
+
// declaration above) exactly once below -- every path (flag off,
|
|
540
|
+
// missing config, success, timeout, or the catch's fail-open) pushes
|
|
541
|
+
// before returning, so `chat.params` always has exactly one entry per
|
|
542
|
+
// turn to shift, in the same relative order these pushes happened.
|
|
543
|
+
let telemetry: CompactionTelemetry = { applied: false }
|
|
544
|
+
try {
|
|
545
|
+
const compactionDecision = await liveCompactionEnabled()
|
|
546
|
+
if (compactionDecision !== true) return
|
|
547
|
+
|
|
548
|
+
const config = readElliotConfig()
|
|
549
|
+
if (!config?.backend_url || !config.team_id || !config.jwt_token) return
|
|
550
|
+
|
|
551
|
+
const { compactionMessages, toolPartsByCallId, passthroughPartsByInfo } = toCompactionMessages(
|
|
552
|
+
output.messages as unknown as NativeMessage[],
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
const asker = makeCompactionAsker({
|
|
556
|
+
backendUrl: config.backend_url,
|
|
557
|
+
teamId: config.team_id,
|
|
558
|
+
jwtToken: config.jwt_token,
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Raced against COMPACTION_TURN_BUDGET_MS (see its declaration
|
|
562
|
+
// above) -- a turn with many stale candidates can't accumulate
|
|
563
|
+
// unbounded latency across sequential asker calls. A timeout here
|
|
564
|
+
// throws into the same catch below as any other failure, so the
|
|
565
|
+
// fail-open behavior is identical either way.
|
|
566
|
+
const result = await Promise.race([
|
|
567
|
+
compactMessages(compactionMessages, { asker }),
|
|
568
|
+
new Promise<never>((_resolve, reject) => {
|
|
569
|
+
setTimeout(() => reject(new Error("compaction turn budget exceeded")), COMPACTION_TURN_BUDGET_MS)
|
|
570
|
+
}),
|
|
571
|
+
])
|
|
572
|
+
const rebuilt = fromCompactionMessages(result.messages, toolPartsByCallId, passthroughPartsByInfo)
|
|
573
|
+
|
|
574
|
+
output.messages.length = 0
|
|
575
|
+
output.messages.push(...(rebuilt as typeof output.messages))
|
|
576
|
+
|
|
577
|
+
// T9 telemetry: surface T6's own already-computed stats verbatim --
|
|
578
|
+
// no new computation -- for `chat.params` to stamp into
|
|
579
|
+
// `metadata.compaction_applied`/`compaction_state_tokens`/
|
|
580
|
+
// `compaction_reduction_ratio` via `stampSessionAffinity`.
|
|
581
|
+
telemetry = {
|
|
582
|
+
applied: true,
|
|
583
|
+
stateTokens: result.stats.stateTokens,
|
|
584
|
+
reductionRatio: result.stats.reductionRatio,
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// Any failure anywhere in the attempt above (live-flag check,
|
|
588
|
+
// adapter, the asker's HTTP call, compactMessages itself, or the
|
|
589
|
+
// turn-budget timeout) -- do nothing. `output.messages` already
|
|
590
|
+
// reflects whatever `stripBlockedTurns` produced; leave it exactly
|
|
591
|
+
// as is. `telemetry` stays `{ applied: false }` (T9).
|
|
592
|
+
} finally {
|
|
593
|
+
pendingCompactionTelemetryQueue.push(telemetry)
|
|
594
|
+
}
|
|
222
595
|
},
|
|
223
596
|
|
|
224
597
|
/**
|
|
@@ -264,7 +637,13 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
264
637
|
* costs at most one network round trip, not two.
|
|
265
638
|
*/
|
|
266
639
|
"chat.params": async (hookInput, output) => {
|
|
267
|
-
|
|
640
|
+
// T9: shift this turn's entry off the FIFO queue `experimental.chat.
|
|
641
|
+
// messages.transform` pushed for it (see the queue's declaration
|
|
642
|
+
// above). `shift()` on an empty queue (a turn that skipped the
|
|
643
|
+
// transform hook entirely) yields `undefined`, defaulted below --
|
|
644
|
+
// never a stale value from a different turn.
|
|
645
|
+
const compactionTelemetry = pendingCompactionTelemetryQueue.shift() ?? { applied: false }
|
|
646
|
+
stampSessionAffinity(output.options, hookInput.sessionID, compactionTelemetry)
|
|
268
647
|
const instanceTeamId = extractInstanceTeamId(output.options)
|
|
269
648
|
const preCallDecision = await liveJevPolicyEnabled({ instanceTeamId })
|
|
270
649
|
applyGuardrailDecision(output.options, preCallDecision)
|
|
@@ -21,16 +21,39 @@
|
|
|
21
21
|
* unchanged pre-existing request still goes out).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
|
|
26
|
+
* pilot.md`, task T9) telemetry handed to `stampSessionAffinity` alongside
|
|
27
|
+
* the session id, so a before/after cost comparison can later be built
|
|
28
|
+
* purely from `LiteLLM_SpendLogs` metadata filtered by `session_id` and
|
|
29
|
+
* `compaction_applied` (see `litellm_spend.py`'s existing per-request
|
|
30
|
+
* `prompt_tokens`/`completion_tokens` fields) -- no new computation here,
|
|
31
|
+
* these are T6's own `CompactionStats` fields, surfaced as-is.
|
|
32
|
+
*/
|
|
33
|
+
export interface CompactionTelemetry {
|
|
34
|
+
readonly applied: boolean
|
|
35
|
+
readonly stateTokens?: number
|
|
36
|
+
readonly reductionRatio?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Stamp the OpenCode session id into a request's `metadata.session_id` so
|
|
26
41
|
* LiteLLM can route every turn of the same session to the same deployment.
|
|
27
42
|
* `options` is the same merged options object `chat.params` hands the caller
|
|
28
43
|
* (`opencode/.../session/llm/request.ts`), the one `metadata.guardrails`
|
|
29
44
|
* already travels in.
|
|
45
|
+
*
|
|
46
|
+
* Also stamps `metadata.compaction_applied` (T9) -- `false`, with
|
|
47
|
+
* `compaction_state_tokens`/`compaction_reduction_ratio` absent, whenever
|
|
48
|
+
* `compaction` is omitted or `compaction.applied` is `false` (flag off, or
|
|
49
|
+
* the compaction attempt in `experimental.chat.messages.transform` failed
|
|
50
|
+
* open); `true` plus both numeric fields when it actually ran and produced
|
|
51
|
+
* a result.
|
|
30
52
|
*/
|
|
31
53
|
export function stampSessionAffinity(
|
|
32
54
|
options: Record<string, unknown> | null | undefined,
|
|
33
55
|
sessionID: string | undefined,
|
|
56
|
+
compaction?: CompactionTelemetry,
|
|
34
57
|
): void {
|
|
35
58
|
if (!options || typeof options !== "object" || !sessionID) return
|
|
36
59
|
const metadata =
|
|
@@ -38,4 +61,13 @@ export function stampSessionAffinity(
|
|
|
38
61
|
? (options.metadata as Record<string, unknown>)
|
|
39
62
|
: (options.metadata = {} as Record<string, unknown>)
|
|
40
63
|
metadata["session_id"] = sessionID
|
|
64
|
+
|
|
65
|
+
metadata["compaction_applied"] = compaction?.applied ?? false
|
|
66
|
+
if (compaction?.applied) {
|
|
67
|
+
metadata["compaction_state_tokens"] = compaction.stateTokens
|
|
68
|
+
metadata["compaction_reduction_ratio"] = compaction.reductionRatio
|
|
69
|
+
} else {
|
|
70
|
+
delete metadata["compaction_state_tokens"]
|
|
71
|
+
delete metadata["compaction_reduction_ratio"]
|
|
72
|
+
}
|
|
41
73
|
}
|