@elliotsystems/eai 0.1.7 → 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 +55 -0
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +152 -23
- package/lib/stage.js +92 -11
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/compaction.ts +600 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +370 -0
- package/plugin/opencode-plugin/src/index.ts +476 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +73 -0
|
@@ -24,6 +24,25 @@ import { configHook } from "./proxy-provider.js"
|
|
|
24
24
|
import { formatScope, scopeForWorktree } from "./scope.js"
|
|
25
25
|
import { applyElliotIdentity } from "./identity.js"
|
|
26
26
|
import { resolveEndUserId, type SessionClient } from "./identity-headers.js"
|
|
27
|
+
import {
|
|
28
|
+
applyGuardrailDecision,
|
|
29
|
+
extractInstanceTeamId,
|
|
30
|
+
liveCompactionEnabled,
|
|
31
|
+
liveJevOutputValidationEnabled,
|
|
32
|
+
liveJevPolicyEnabled,
|
|
33
|
+
OUTPUT_VALIDATION_GUARDRAIL_NAME,
|
|
34
|
+
} from "./guardrail-sync.js"
|
|
35
|
+
import { stampSessionAffinity, type CompactionTelemetry } from "./session-affinity.js"
|
|
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"
|
|
27
46
|
|
|
28
47
|
// Structural mirrors of the host types (packages/plugin/src/index.ts @
|
|
29
48
|
// v1.18.19). Declared locally so this package type-checks and unit-tests
|
|
@@ -72,10 +91,43 @@ export interface Hooks {
|
|
|
72
91
|
input: { sessionID?: string; model: unknown },
|
|
73
92
|
output: { system: string[] },
|
|
74
93
|
) => Promise<void>
|
|
94
|
+
/**
|
|
95
|
+
* Runs right before the session's message history is converted to the
|
|
96
|
+
* provider's wire format (session/prompt.ts, immediately before
|
|
97
|
+
* `MessageV2.toModelMessagesEffect`) — the one hook that sees, and can
|
|
98
|
+
* edit, exactly what the MODEL is about to be shown as this conversation's
|
|
99
|
+
* history. `output.messages` is the SAME array the caller goes on to use
|
|
100
|
+
* (session/plugin/index.ts's `trigger` never reassigns it), so a hook
|
|
101
|
+
* must mutate it in place; see blocked-turns.ts's own `stripBlockedTurns`.
|
|
102
|
+
*/
|
|
103
|
+
"experimental.chat.messages.transform"?: (
|
|
104
|
+
input: Record<string, never>,
|
|
105
|
+
output: { messages: Array<{ info?: Record<string, unknown>; parts?: unknown[] }> },
|
|
106
|
+
) => Promise<void>
|
|
75
107
|
"chat.headers"?: (
|
|
76
108
|
input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
|
|
77
109
|
output: { headers: Record<string, string> },
|
|
78
110
|
) => Promise<void>
|
|
111
|
+
/**
|
|
112
|
+
* Runs on every outgoing request, before it is sent — the one hook with
|
|
113
|
+
* write access to `options`, the object `eai setup`'s static `metadata.
|
|
114
|
+
* guardrails` lands in (opencode/packages/opencode/src/session/llm/
|
|
115
|
+
* request.ts: `options = mergeOptions(base, model.options, ...)` is what
|
|
116
|
+
* this hook receives as `output.options`). See guardrail-sync.ts for why
|
|
117
|
+
* this is the CLI's real session-recovery mechanism: there is no response/
|
|
118
|
+
* error hook to retry FROM, so the fix is making the one attempt correct
|
|
119
|
+
* instead.
|
|
120
|
+
*/
|
|
121
|
+
"chat.params"?: (
|
|
122
|
+
input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
|
|
123
|
+
output: {
|
|
124
|
+
temperature?: number
|
|
125
|
+
topP?: number
|
|
126
|
+
topK?: number
|
|
127
|
+
maxOutputTokens?: number
|
|
128
|
+
options: Record<string, unknown>
|
|
129
|
+
},
|
|
130
|
+
) => Promise<void>
|
|
79
131
|
config?: (input: any) => Promise<void>
|
|
80
132
|
auth?: AuthHook
|
|
81
133
|
dispose?: () => Promise<void>
|
|
@@ -83,6 +135,256 @@ export interface Hooks {
|
|
|
83
135
|
|
|
84
136
|
export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
|
|
85
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
|
+
|
|
86
388
|
export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
87
389
|
// Fail loudly at load on bad options — a plugin that limps with a typo'd
|
|
88
390
|
// config is worse than one that says why it refused to start.
|
|
@@ -92,6 +394,47 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
92
394
|
// the one source the model cannot influence.
|
|
93
395
|
const worktree = input.worktree || input.directory
|
|
94
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
|
+
|
|
95
438
|
return {
|
|
96
439
|
/**
|
|
97
440
|
* One handler, two jobs — a JS object literal may carry only one
|
|
@@ -163,6 +506,94 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
163
506
|
applyElliotIdentity(output.system)
|
|
164
507
|
},
|
|
165
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Keeps a Jev-blocked prompt from leaking into the MODEL's own view of
|
|
511
|
+
* this conversation's history on every LATER turn (see blocked-turns.ts's
|
|
512
|
+
* own module docstring for the full "why" — a benign follow-up was
|
|
513
|
+
* getting answered alongside an unprompted refusal about the earlier,
|
|
514
|
+
* never-actually-sent request). Purely a context-shaping step: the
|
|
515
|
+
* stored session history (what the UI's own transcript reads) is
|
|
516
|
+
* untouched, only what gets sent to the model next.
|
|
517
|
+
*/
|
|
518
|
+
"experimental.chat.messages.transform": async (_hookInput, output) => {
|
|
519
|
+
const filtered = stripBlockedTurns(output.messages)
|
|
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
|
+
}
|
|
595
|
+
},
|
|
596
|
+
|
|
166
597
|
/**
|
|
167
598
|
* Per-message LiteLLM `end_user` override for a TEAM-shared runtime. See
|
|
168
599
|
* ./identity-headers.ts for the full rationale. A no-op (static header
|
|
@@ -175,6 +606,51 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
175
606
|
if (userId) output.headers["x-litellm-end-user-id"] = userId
|
|
176
607
|
},
|
|
177
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The CLI's Jev guardrail session-recovery fix (see guardrail-sync.ts's
|
|
611
|
+
* own module docstring for the full "why this hook, not a retry" case).
|
|
612
|
+
* A `null` live decision (no team context, or the check itself failed —
|
|
613
|
+
* offline, backend down) changes nothing: whatever `eai setup` baked
|
|
614
|
+
* into this model's static `options.metadata.guardrails` is left
|
|
615
|
+
* exactly as it was, so this can only ever make a stale CLI config
|
|
616
|
+
* MORE correct, never less available.
|
|
617
|
+
*
|
|
618
|
+
* Also stamps `metadata.session_id` (see session-affinity.ts) so
|
|
619
|
+
* LiteLLM's `session_affinity` pre-call check can pin every turn of
|
|
620
|
+
* this session to the same upstream deployment — the mechanism that
|
|
621
|
+
* keeps DeepSeek's implicit prefix cache warm across a long agent
|
|
622
|
+
* session (deploy/litellm/config.yaml, `optional_pre_call_checks`).
|
|
623
|
+
*
|
|
624
|
+
* `instanceTeamId` (see guardrail-sync.ts's own docstring on
|
|
625
|
+
* `LiveCheckDeps`) is extracted from THIS request's own `options`
|
|
626
|
+
* rather than trusted from `~/.elliot/config.json` — on a shared-
|
|
627
|
+
* filesystem local-dev backend runtime, those two can disagree (a real
|
|
628
|
+
* person's own CLI login vs. the team this Web Chat request actually
|
|
629
|
+
* belongs to), and the live check must not apply one team's setting to
|
|
630
|
+
* another's request.
|
|
631
|
+
*
|
|
632
|
+
* Jev audit (2026-10, G2): both the pre-call (`jev-policy`) AND
|
|
633
|
+
* post-call (`jev-output-validation`) toggles are now live-synced here
|
|
634
|
+
* — previously only the pre-call flag was. `liveJevPolicyEnabled`/
|
|
635
|
+
* `liveJevOutputValidationEnabled` share one cached `GET /teams/{id}`
|
|
636
|
+
* fetch under the hood (see guardrail-sync.ts), so checking both here
|
|
637
|
+
* costs at most one network round trip, not two.
|
|
638
|
+
*/
|
|
639
|
+
"chat.params": async (hookInput, output) => {
|
|
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)
|
|
647
|
+
const instanceTeamId = extractInstanceTeamId(output.options)
|
|
648
|
+
const preCallDecision = await liveJevPolicyEnabled({ instanceTeamId })
|
|
649
|
+
applyGuardrailDecision(output.options, preCallDecision)
|
|
650
|
+
const outputValidationDecision = await liveJevOutputValidationEnabled({ instanceTeamId })
|
|
651
|
+
applyGuardrailDecision(output.options, outputValidationDecision, OUTPUT_VALIDATION_GUARDRAIL_NAME)
|
|
652
|
+
},
|
|
653
|
+
|
|
178
654
|
"permission.ask": async (permInput, output) => {
|
|
179
655
|
if (permInput.type !== "bash") return
|
|
180
656
|
const command = permInput.metadata?.["command"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request LiteLLM session-affinity stamping.
|
|
3
|
+
*
|
|
4
|
+
* LiteLLM v1.101.0's `DeploymentAffinityCheck` — switched on by
|
|
5
|
+
* `optional_pre_call_checks: ["session_affinity"]` in
|
|
6
|
+
* deploy/litellm/config.yaml — pins consecutive turns of one conversation to
|
|
7
|
+
* the SAME upstream deployment, so DeepSeek's implicit prefix cache stays on
|
|
8
|
+
* the warm node. Verified in the v1.101.0 source
|
|
9
|
+
* (`deployment_affinity_check.py::_get_session_id_from_metadata_dict`): it
|
|
10
|
+
* keys on `metadata.session_id` in the REQUEST METADATA, NOT on the
|
|
11
|
+
* `x-session-affinity` / `x-opencode-session-id` HEADERS OpenCode already
|
|
12
|
+
* sends on every turn (session/llm/request.ts) — those are ignored for
|
|
13
|
+
* affinity in this version. Without this stamp, `session_affinity` in the
|
|
14
|
+
* router config would be dead config.
|
|
15
|
+
*
|
|
16
|
+
* This hook writes the id into `options.metadata.session_id` — the exact
|
|
17
|
+
* metadata channel the existing `metadata.guardrails` mechanism
|
|
18
|
+
* (guardrail-sync.ts) already proves reaches LiteLLM's request metadata end
|
|
19
|
+
* to end. Never throws, never fabricates: a missing session id or a
|
|
20
|
+
* non-object `metadata` simply leaves the request unstamped (the static,
|
|
21
|
+
* unchanged pre-existing request still goes out).
|
|
22
|
+
*/
|
|
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
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stamp the OpenCode session id into a request's `metadata.session_id` so
|
|
41
|
+
* LiteLLM can route every turn of the same session to the same deployment.
|
|
42
|
+
* `options` is the same merged options object `chat.params` hands the caller
|
|
43
|
+
* (`opencode/.../session/llm/request.ts`), the one `metadata.guardrails`
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
export function stampSessionAffinity(
|
|
54
|
+
options: Record<string, unknown> | null | undefined,
|
|
55
|
+
sessionID: string | undefined,
|
|
56
|
+
compaction?: CompactionTelemetry,
|
|
57
|
+
): void {
|
|
58
|
+
if (!options || typeof options !== "object" || !sessionID) return
|
|
59
|
+
const metadata =
|
|
60
|
+
options.metadata && typeof options.metadata === "object" && !Array.isArray(options.metadata)
|
|
61
|
+
? (options.metadata as Record<string, unknown>)
|
|
62
|
+
: (options.metadata = {} as Record<string, unknown>)
|
|
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
|
+
}
|
|
73
|
+
}
|