@vellumai/assistant 0.8.8-dev.202606071935.547b6d2 → 0.8.8-dev.202606072033.0e97ff6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.8-dev.202606071935.547b6d2",
3
+ "version": "0.8.8-dev.202606072033.0e97ff6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,8 +22,8 @@ import type {
22
22
  CheckpointInfo,
23
23
  } from "../agent/loop.js";
24
24
  import { AgentLoop, isMaxTokensStopReason } from "../agent/loop.js";
25
+ import type { TrustContext } from "../daemon/trust-context.js";
25
26
  import type { PostCompactionHookInput } from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
26
- import type { TurnContext } from "../plugins/types.js";
27
27
  import type {
28
28
  Message,
29
29
  Provider,
@@ -120,21 +120,16 @@ const userMessage: Message = {
120
120
  content: [{ type: "text", text: "Hello" }],
121
121
  };
122
122
 
123
- // A turn context plus a context-window manager whose `maybeCompact` returns a
123
+ // A trust snapshot plus a context-window manager whose `maybeCompact` returns a
124
124
  // canned result, so the loop's compaction call runs without the real
125
- // orchestrator machinery. The manager is supplied to the loop as a dedicated
126
- // run option rather than riding on the turn context.
125
+ // orchestrator machinery. Both are supplied to the loop as dedicated run
126
+ // options.
127
127
  function fakeCompaction(result: { compacted: boolean; exhausted: boolean }): {
128
- turnContext: TurnContext;
128
+ trust: TrustContext;
129
129
  contextWindowManager: AgentLoopRunOptions["contextWindowManager"];
130
130
  } {
131
131
  return {
132
- turnContext: {
133
- requestId: "req-compact",
134
- conversationId: "conv-compact",
135
- turnIndex: 0,
136
- trust: { sourceChannel: "vellum", trustClass: "unknown" },
137
- },
132
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
138
133
  contextWindowManager: {
139
134
  maybeCompact: async () => result,
140
135
  } as unknown as AgentLoopRunOptions["contextWindowManager"],
@@ -190,7 +190,6 @@ let mockCompactResult: ContextWindowResult = {
190
190
  mock.module("../context/window-manager.js", () => ({
191
191
  ContextWindowManager: class {
192
192
  nonPersistedPrefixCount = 0;
193
- summaryIsInjected = false;
194
193
  constructor() {}
195
194
  updateConfig() {}
196
195
  shouldCompact() {
@@ -1019,20 +1019,17 @@ describe("applyRuntimeInjections — injection mode", () => {
1019
1019
  expect(texts).toContain("Hello");
1020
1020
  });
1021
1021
 
1022
- test("post-compaction re-injection resolves the live conversation from the turn context", async () => {
1022
+ test("post-compaction re-injection resolves the live conversation from the conversation id", async () => {
1023
1023
  // GIVEN the seeded live conversation `injection-mode-conv` whose
1024
1024
  // conversation-scoped blocks (`<active_workspace>`, `<channel_capabilities>`)
1025
1025
  // only resolve when `applyRuntimeInjections` finds it by conversation id
1026
- // AND the agent loop hands the post-compaction hook its per-turn context as
1027
- // a single nested `turnContext` (its own working-state unit)
1026
+ // AND the agent loop hands the post-compaction hook the turn-identity fields
1027
+ // flat (`requestId`, `conversationId`, `trust`)
1028
1028
  const { messages: result } = await postCompactReinject({
1029
1029
  history: baseMessages,
1030
- turnContext: {
1031
- requestId: "reinject-req",
1032
- conversationId: "injection-mode-conv",
1033
- turnIndex: 0,
1034
- trust: { sourceChannel: "vellum", trustClass: "guardian" },
1035
- },
1030
+ requestId: "reinject-req",
1031
+ conversationId: "injection-mode-conv",
1032
+ trust: { sourceChannel: "vellum", trustClass: "guardian" },
1036
1033
  isNonInteractive: false,
1037
1034
  mode: "full",
1038
1035
  modelProfile: null,
@@ -1043,9 +1040,9 @@ describe("applyRuntimeInjections — injection mode", () => {
1043
1040
  .map((b) => b.text)
1044
1041
  .join("\n");
1045
1042
 
1046
- // THEN the hook unnests `turnContext.conversationId` onto the flat injection
1047
- // options, so the re-injection resolves the same live conversation as the
1048
- // turn's initial assembly rather than the synthetic fallback.
1043
+ // THEN the hook forwards `conversationId` onto the flat injection options,
1044
+ // so the re-injection resolves the same live conversation as the turn's
1045
+ // initial assembly rather than the synthetic fallback.
1049
1046
  expect(allText).toContain("<active_workspace>");
1050
1047
  expect(allText).toContain("<channel_capabilities>");
1051
1048
  });
package/src/agent/loop.ts CHANGED
@@ -16,6 +16,10 @@ import type {
16
16
  } from "../context/window-manager.js";
17
17
  import type { InboundActorContext } from "../daemon/conversation-runtime-assembly.js";
18
18
  import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
19
+ import {
20
+ FALLBACK_TURN_TRUST,
21
+ type TrustContext,
22
+ } from "../daemon/trust-context.js";
19
23
  import { HOOKS } from "../plugin-api/constants.js";
20
24
  import type {
21
25
  AssistantMessageContext,
@@ -29,7 +33,7 @@ import {
29
33
  } from "../plugins/defaults/compaction/compact.js";
30
34
  import postCompactReinject from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
31
35
  import { runHook } from "../plugins/pipeline.js";
32
- import type { CompactionCircuitEvent, TurnContext } from "../plugins/types.js";
36
+ import type { CompactionCircuitEvent } from "../plugins/types.js";
33
37
  import { PluginExecutionError } from "../plugins/types.js";
34
38
  import { normalizeThinkingConfigForWire } from "../providers/thinking-config.js";
35
39
  import type {
@@ -403,37 +407,6 @@ function assistantTextOf(content: ReadonlyArray<ContentBlock>): string {
403
407
  return text;
404
408
  }
405
409
 
406
- /**
407
- * Build a minimal {@link TurnContext} for pipeline invocations inside the
408
- * agent loop. Real production call sites thread a full `TurnContext` into
409
- * `AgentLoop.run()` (see the `turnContext` parameter on
410
- * {@link AgentLoop.run}); this helper is the fallback used only by unit
411
- * tests that construct `AgentLoop` directly without an orchestrator.
412
- *
413
- * When the orchestrator-supplied context is present it is used directly so the
414
- * pipeline sees the real `conversationId` and trust class.
415
- * In the fallback path the returned context is still useful for pipeline
416
- * logging: `requestId` surfaces in every structured record, and `turnIndex`
417
- * reflects the current tool-use iteration.
418
- */
419
- function buildLoopTurnContext(
420
- requestId: string | undefined,
421
- turnIndex: number,
422
- ): TurnContext {
423
- return {
424
- requestId: requestId ?? "agent-loop",
425
- // Loop-scoped pipelines do not currently carry a conversation ID; the
426
- // outer orchestrator owns that dimension. Use a fixed sentinel so log
427
- // consumers can filter loop-origin records out of conversation queries.
428
- conversationId: "agent-loop",
429
- turnIndex,
430
- trust: {
431
- sourceChannel: "vellum",
432
- trustClass: "unknown",
433
- },
434
- };
435
- }
436
-
437
410
  /**
438
411
  * User-config HTTP status codes that should never page the on-call: billing
439
412
  * exhaustion (402), invalid credentials (401), and forbidden/plan-gated (403).
@@ -484,13 +457,15 @@ export interface AgentLoopRunOptions {
484
457
  ) => CheckpointDecision | Promise<CheckpointDecision>;
485
458
  callSite?: LLMCallSite;
486
459
  /**
487
- * Per-turn context supplied by the orchestrator. Every pipeline
488
- * invocation inside the loop clones from this value (overwriting only
489
- * `turnIndex`/`requestId`) so middleware sees the real conversation
490
- * identity and trust class rather than the `"agent-loop"` sentinel used
491
- * when the loop is instantiated standalone in unit tests.
460
+ * Trust classification and channel identity for the turn's inbound actor,
461
+ * supplied by the orchestrator as the turn-start snapshot. Read only on the
462
+ * mid-loop in-place compaction path — to scope the compactor's image
463
+ * manifest (guardian-only attachments are excluded for untrusted actors) and
464
+ * forwarded to {@link postCompactReinject}. Absent for callers without a
465
+ * compaction path (agent wakes, standalone unit tests); the loop otherwise
466
+ * resolves its conversation identity from its own {@link conversationId}.
492
467
  */
493
- turnContext?: TurnContext;
468
+ trust?: TrustContext;
494
469
  /**
495
470
  * The conversation's {@link ContextWindowManager}, supplied by the
496
471
  * orchestrator so mid-loop in-place compaction can run against the real
@@ -623,6 +598,15 @@ export class AgentLoop {
623
598
  | null;
624
599
  private toolExecutor: LoopToolExecutor | null;
625
600
 
601
+ /**
602
+ * Conversation this loop drives, or an empty string for standalone test
603
+ * loops constructed without one. Source of truth for the `conversationId`
604
+ * the loop's pipeline contexts and post-compaction re-injection resolve the
605
+ * live conversation through, so the loop no longer needs a turn context
606
+ * threaded in to know its own identity.
607
+ */
608
+ private readonly conversationId: string;
609
+
626
610
  /**
627
611
  * Loop-held compaction circuit breaker. The loop has a 1:1 lifetime with its
628
612
  * conversation, so it is the source of truth for the cross-turn failure
@@ -652,7 +636,8 @@ export class AgentLoop {
652
636
  this.resolveTools = resolveTools ?? null;
653
637
  this.resolveSystemPrompt = resolveSystemPrompt ?? null;
654
638
  this.toolExecutor = toolExecutor ?? null;
655
- this.compactionCircuit = new CompactionCircuit(conversationId ?? "");
639
+ this.conversationId = conversationId ?? "";
640
+ this.compactionCircuit = new CompactionCircuit(this.conversationId);
656
641
  }
657
642
 
658
643
  /**
@@ -704,7 +689,7 @@ export class AgentLoop {
704
689
  * compaction outcome into a user-visible turn failure.
705
690
  */
706
691
  private async recordCompactionOutcome(
707
- turnContext: TurnContext,
692
+ requestId: string | undefined,
708
693
  summaryFailed: boolean,
709
694
  onEvent: (event: AgentEvent) => void | Promise<void>,
710
695
  ): Promise<void> {
@@ -712,7 +697,7 @@ export class AgentLoop {
712
697
  await this.compactionCircuit.recordOutcome(summaryFailed, onEvent);
713
698
  } catch (recordError) {
714
699
  log.error(
715
- { err: recordError, requestId: turnContext.requestId },
700
+ { err: recordError, requestId },
716
701
  "Recording a compaction outcome against the circuit breaker failed; suppressing to keep the agent loop alive",
717
702
  );
718
703
  }
@@ -728,7 +713,8 @@ export class AgentLoop {
728
713
  */
729
714
  private async compact(
730
715
  history: Message[],
731
- turnContext: TurnContext,
716
+ requestId: string | undefined,
717
+ trust: TrustContext,
732
718
  manager: ContextWindowManager | undefined,
733
719
  signal: AbortSignal | undefined,
734
720
  onEvent: (event: AgentEvent) => void | Promise<void>,
@@ -752,8 +738,8 @@ export class AgentLoop {
752
738
  }
753
739
  // The mid-loop budget gate is reached only when this turn decides to
754
740
  // compact in place, so `force` past the auto-threshold check.
755
- // `actorTrustClass` comes from the turn context (the actor whose turn
756
- // triggered compaction) so the compactor's image manifest excludes
741
+ // `actorTrustClass` comes from the turn's trust snapshot (the actor whose
742
+ // turn triggered compaction) so the compactor's image manifest excludes
757
743
  // guardian-only attachments for untrusted actors. `overrideProfile` is the
758
744
  // turn's resolved inference-profile override for the summary call.
759
745
  const compactResult = await defaultCompact({
@@ -761,7 +747,7 @@ export class AgentLoop {
761
747
  messages: rawHistory,
762
748
  signal,
763
749
  force: true,
764
- actorTrustClass: turnContext.trust.trustClass,
750
+ actorTrustClass: trust.trustClass,
765
751
  overrideProfile,
766
752
  });
767
753
  // `force: true` bypasses the auto-threshold gate, but early returns
@@ -770,7 +756,7 @@ export class AgentLoop {
770
756
  // actually ran.
771
757
  if (compactResult.summaryFailed !== undefined) {
772
758
  await this.recordCompactionOutcome(
773
- turnContext,
759
+ requestId,
774
760
  compactResult.summaryFailed,
775
761
  onEvent,
776
762
  );
@@ -792,7 +778,9 @@ export class AgentLoop {
792
778
  // pre-compaction history otherwise.
793
779
  const injection = await postCompactReinject({
794
780
  history: compactResult.compacted ? compactResult.messages : rawHistory,
795
- turnContext,
781
+ requestId,
782
+ conversationId: this.conversationId,
783
+ trust,
796
784
  isNonInteractive,
797
785
  // Mid-loop re-injection always runs at full injection volume.
798
786
  mode: "full",
@@ -812,7 +800,7 @@ export class AgentLoop {
812
800
  requestId,
813
801
  onCheckpoint,
814
802
  callSite,
815
- turnContext,
803
+ trust,
816
804
  overrideProfile,
817
805
  resolveOverrideProfile,
818
806
  resolveContextWindow,
@@ -880,13 +868,6 @@ export class AgentLoop {
880
868
  let toolUseBlocks: Extract<ContentBlock, { type: "tool_use" }>[] = [];
881
869
 
882
870
  try {
883
- // Per-turn pipeline context. Real call sites thread a full
884
- // `turnContext` into `run()` and it is used directly; standalone
885
- // unit-test instantiations that never plumb a context through fall
886
- // back to a synthesized placeholder scoped to the tool-use iteration.
887
- const turnCtx =
888
- turnContext ?? buildLoopTurnContext(requestId, toolUseTurns);
889
-
890
871
  // ── Pre-call budget gate ─────────────────────────────────────
891
872
  // When overflow recovery is enabled, estimate the running context
892
873
  // size as it approaches the preflight budget before issuing the
@@ -929,7 +910,8 @@ export class AgentLoop {
929
910
  );
930
911
  const compacted = await this.compact(
931
912
  history,
932
- turnCtx,
913
+ requestId,
914
+ trust ?? FALLBACK_TURN_TRUST,
933
915
  contextWindowManager,
934
916
  signal,
935
917
  onEvent,
@@ -1048,14 +1030,14 @@ export class AgentLoop {
1048
1030
  providerConfig.callSite = callSite;
1049
1031
  providerConfig.usageTracking = "manual";
1050
1032
  // Per-conversation seed for deterministic `mix`-profile expansion.
1051
- // Sourced from the orchestrator-supplied turn context's
1052
- // conversationId so every LLM call in a conversation resolves the
1053
- // same mix arm (stable across turns and retries, and across daemon
1054
- // restarts since the seed is the durable conversation id). Absent
1055
- // for standalone `AgentLoop` instances (unit tests / no turnContext)
1056
- // — those fall back to per-call random mix selection.
1057
- if (turnContext?.conversationId) {
1058
- providerConfig.selectionSeed = turnContext.conversationId;
1033
+ // Sourced from the loop's own conversation id so every LLM call in a
1034
+ // conversation resolves the same mix arm (stable across turns and
1035
+ // retries, and across daemon restarts since the seed is the durable
1036
+ // conversation id). Absent for standalone `AgentLoop` instances
1037
+ // (unit tests constructed without a conversation id) — those fall
1038
+ // back to per-call random mix selection.
1039
+ if (this.conversationId) {
1040
+ providerConfig.selectionSeed = this.conversationId;
1059
1041
  }
1060
1042
  }
1061
1043
 
@@ -1190,8 +1172,8 @@ export class AgentLoop {
1190
1172
  // request unchanged and streaming live.
1191
1173
  try {
1192
1174
  const preModelCtx: PreModelCallContext = {
1193
- conversationId: turnCtx.conversationId,
1194
- callSite: turnCtx.callSite,
1175
+ conversationId: this.conversationId,
1176
+ callSite,
1195
1177
  systemPrompt: providerOptions.systemPrompt,
1196
1178
  deferAssistantOutput: false,
1197
1179
  logger: rlog,
@@ -1308,8 +1290,8 @@ export class AgentLoop {
1308
1290
  let finalized = message;
1309
1291
  try {
1310
1292
  const ctx: AssistantMessageContext = {
1311
- conversationId: turnCtx.conversationId,
1312
- callSite: turnCtx.callSite,
1293
+ conversationId: this.conversationId,
1294
+ callSite,
1313
1295
  content: structuredClone(message.content),
1314
1296
  stopReason: response.stopReason,
1315
1297
  logger: rlog,
@@ -1420,7 +1402,7 @@ export class AgentLoop {
1420
1402
  // follow-up turn. It receives the full history and, when it asks to
1421
1403
  // continue, appends the follow-up turn itself.
1422
1404
  const stopCtx: StopContext = {
1423
- conversationId: turnCtx.conversationId,
1405
+ conversationId: this.conversationId,
1424
1406
  messages: [...history],
1425
1407
  responseContent: response.content,
1426
1408
  stopReason: response.stopReason,
@@ -1615,7 +1597,7 @@ export class AgentLoop {
1615
1597
  continue;
1616
1598
  }
1617
1599
  const postToolUseCtx: PostToolUseContext = {
1618
- conversationId: turnCtx.conversationId,
1600
+ conversationId: this.conversationId,
1619
1601
  toolResponse: block as ToolResultContent,
1620
1602
  messages: history,
1621
1603
  maxInputTokens: contextWindowTokens,
@@ -199,8 +199,7 @@ export class ContextWindowManager {
199
199
  * `compactedMessages` so `compactedPersistedMessages` only reflects DB
200
200
  * rows. Decremented after a successful compaction.
201
201
  */
202
- nonPersistedPrefixCount = 0;
203
- summaryIsInjected = false;
202
+ private _nonPersistedPrefixCount = 0;
204
203
  private _resolvedSystemPrompt: string | undefined;
205
204
 
206
205
  constructor(options: ContextWindowManagerOptions) {
@@ -216,6 +215,21 @@ export class ContextWindowManager {
216
215
  this.config = config;
217
216
  }
218
217
 
218
+ /** Leading non-persisted inherited-context messages the compactor preserves. */
219
+ get nonPersistedPrefixCount(): number {
220
+ return this._nonPersistedPrefixCount;
221
+ }
222
+
223
+ /**
224
+ * Seed the non-persisted inherited-context prefix when a forked/sub
225
+ * conversation inherits its parent's in-memory context. The compactor folds
226
+ * this prefix into the summary and {@link consumeCompactionState} decrements
227
+ * it as the leading messages are compacted away.
228
+ */
229
+ seedNonPersistedPrefix(count: number): void {
230
+ this._nonPersistedPrefixCount = count;
231
+ }
232
+
219
233
  private get estimationProviderName(): string {
220
234
  return this.provider.tokenEstimationProvider ?? this.provider.name;
221
235
  }
@@ -353,7 +367,7 @@ export class ContextWindowManager {
353
367
  signal,
354
368
  overrideProfile: options?.overrideProfile ?? null,
355
369
  actorTrustClass: options?.actorTrustClass,
356
- nonPersistedPrefixCount: this.nonPersistedPrefixCount,
370
+ nonPersistedPrefixCount: this._nonPersistedPrefixCount,
357
371
  };
358
372
 
359
373
  // Retry budget for the compactor itself. Lives here (not in the
@@ -391,7 +405,7 @@ export class ContextWindowManager {
391
405
  ...baseArgs,
392
406
  messages: result.messages,
393
407
  previousEstimatedInputTokens: previousEstimate,
394
- nonPersistedPrefixCount: this.nonPersistedPrefixCount,
408
+ nonPersistedPrefixCount: this._nonPersistedPrefixCount,
395
409
  });
396
410
  if (!nextResult.compacted) break;
397
411
  const nextEstimate = this.recomputePostCompactionEstimate(
@@ -435,21 +449,20 @@ export class ContextWindowManager {
435
449
  }
436
450
 
437
451
  /**
438
- * Decrement the non-persisted prefix bookkeeping and clear the
439
- * injected-summary flag after a productive compaction. Called once per
440
- * successful internal attempt so multi-attempt runs keep the count
441
- * honest as the leading injected messages get folded into the summary.
452
+ * Decrement the non-persisted prefix bookkeeping after a productive
453
+ * compaction. Called once per successful internal attempt so multi-attempt
454
+ * runs keep the count honest as the leading injected messages get folded
455
+ * into the summary.
442
456
  */
443
457
  private consumeCompactionState(compactedMessages: number): void {
444
458
  const compactedAway = Math.min(
445
- this.nonPersistedPrefixCount,
459
+ this._nonPersistedPrefixCount,
446
460
  compactedMessages,
447
461
  );
448
- this.nonPersistedPrefixCount = Math.max(
462
+ this._nonPersistedPrefixCount = Math.max(
449
463
  0,
450
- this.nonPersistedPrefixCount - compactedAway,
464
+ this._nonPersistedPrefixCount - compactedAway,
451
465
  );
452
- this.summaryIsInjected = false;
453
466
  }
454
467
  }
455
468
 
@@ -83,7 +83,6 @@ import userPromptSubmitMemoryRetrieval, {
83
83
  type MemoryRetrievalHookContext,
84
84
  } from "../plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.js";
85
85
  import { runHook } from "../plugins/pipeline.js";
86
- import type { TurnContext as PluginTurnContext } from "../plugins/types.js";
87
86
  import type { ContentBlock, Message } from "../providers/types.js";
88
87
  import type { Provider } from "../providers/types.js";
89
88
  import {
@@ -185,11 +184,6 @@ function formatDiskPressureBlockedMessage(): string {
185
184
  }
186
185
 
187
186
  // ── Plugin pipeline helpers ──────────────────────────────────────────
188
- //
189
- // Canonical {@link PluginTurnContext} builder threaded into every
190
- // `runHook` call inside `runAgentLoopImpl`. The orchestrator composes
191
- // the context on demand at each call site from ambient state rather than
192
- // carrying a persistent `TurnContext` instance across the turn.
193
187
 
194
188
  /**
195
189
  * Synthetic fallback trust context used when the orchestrator fires a hook
@@ -314,8 +308,8 @@ export async function runAgentLoopImpl(
314
308
  // `resolveCallSiteConfig`, picking up any user overrides under
315
309
  // `llm.callSites.mainAgent` (falling back to `llm.default` when absent).
316
310
  const turnCallSite: LLMCallSite = options?.callSite ?? "mainAgent";
317
- // Expose the turn's call site to plugin pipeline/injector contexts (read by
318
- // buildPluginTurnContext) so plugins can scope behaviour to the main reply.
311
+ // Expose the turn's call site on the live conversation so the runtime
312
+ // injection assembly self-resolves it for the turn's plugin contexts.
319
313
  ctx.currentCallSite = turnCallSite;
320
314
 
321
315
  // Optional per-turn inference-profile override. Plumbed through to every
@@ -1228,22 +1222,14 @@ export async function runAgentLoopImpl(
1228
1222
 
1229
1223
  rlog.info({ callSite: turnCallSite }, "Starting agent loop run");
1230
1224
 
1231
- // Thread the orchestrator's canonical per-turn context into the agent
1232
- // loop so its internal pipeline invocations (e.g. compaction) see the
1233
- // real conversation identity / trust / contextWindowManager instead of
1234
- // the synthesized `"agent-loop"` placeholder. The loop clones this value
1235
- // and overwrites `turnIndex` with its own tool-use iteration counter.
1236
- // Trust prefers the per-turn snapshot, then the conversation-level
1225
+ // Trust snapshot the loop forwards to its mid-loop in-place compaction
1226
+ // (scoping the compactor's image manifest) and the post-compaction
1227
+ // re-injection. Prefers the per-turn snapshot, then the conversation-level
1237
1228
  // context, then the fallback — matching the trust the runtime injection
1238
- // assembly resolves for the same turn.
1239
- const loopTurnCtx: PluginTurnContext = {
1240
- requestId: reqId,
1241
- conversationId: ctx.conversationId,
1242
- turnIndex: ctx.turnCount,
1243
- trust:
1244
- ctx.currentTurnTrustContext ?? ctx.trustContext ?? FALLBACK_TURN_TRUST,
1245
- callSite: ctx.currentCallSite,
1246
- };
1229
+ // assembly resolves for the same turn. The loop's other turn-identity
1230
+ // fields self-resolve from its own conversation id.
1231
+ const loopTrust =
1232
+ ctx.currentTurnTrustContext ?? ctx.trustContext ?? FALLBACK_TURN_TRUST;
1247
1233
 
1248
1234
  /**
1249
1235
  * Shared closure: runs the agent loop with the orchestrator's turn
@@ -1263,7 +1249,7 @@ export async function runAgentLoopImpl(
1263
1249
  requestId: reqId,
1264
1250
  onCheckpoint,
1265
1251
  callSite: turnCallSite,
1266
- turnContext: loopTurnCtx,
1252
+ trust: loopTrust,
1267
1253
  contextWindowManager: ctx.contextWindowManager,
1268
1254
  overrideProfile: turnOverrideProfile,
1269
1255
  resolveOverrideProfile: resolveCurrentOverrideProfile,
@@ -14,6 +14,7 @@ import { enqueueMemoryJob, isMemoryEnabled } from "../memory/jobs-store.js";
14
14
  import { enqueueMemoryRetrospectiveIfEnabled } from "../memory/memory-retrospective-enqueue.js";
15
15
  import type { PermissionPrompter } from "../permissions/prompter.js";
16
16
  import type { SecretPrompter } from "../permissions/secret-prompter.js";
17
+ import { disposeContextWindowManager } from "../plugins/defaults/compaction/manager-store.js";
17
18
  import type { ContentBlock, Message } from "../providers/types.js";
18
19
  import {
19
20
  isUntrustedTrustClass,
@@ -241,4 +242,5 @@ export function disposeConversation(ctx: DisposeContext): void {
241
242
  ctx.accumulatedSurfaceState.clear();
242
243
  ctx.lastSurfaceAction.clear();
243
244
  ctx.workspaceTopLevelContext = null;
245
+ disposeContextWindowManager(ctx.conversationId);
244
246
  }
@@ -34,10 +34,9 @@ import { resolveCallSiteConfig } from "../config/llm-resolver.js";
34
34
  import { getConfig } from "../config/loader.js";
35
35
  import type { LLMCallSite, Speed } from "../config/schemas/llm.js";
36
36
  import {
37
- ContextWindowManager,
37
+ type ContextWindowManager,
38
38
  type ContextWindowResult,
39
39
  createContextSummaryMessage,
40
- getSummaryFromContextMessage,
41
40
  } from "../context/window-manager.js";
42
41
  import type { CesClient } from "../credential-execution/client.js";
43
42
  import { EventBus } from "../events/bus.js";
@@ -64,6 +63,7 @@ import { PermissionPrompter } from "../permissions/prompter.js";
64
63
  import { SecretPrompter } from "../permissions/secret-prompter.js";
65
64
  import type { UserDecision } from "../permissions/types.js";
66
65
  import { defaultCompact } from "../plugins/defaults/compaction/compact.js";
66
+ import { createContextWindowManager } from "../plugins/defaults/compaction/manager-store.js";
67
67
  import { repairHistory } from "../plugins/defaults/history-repair/terminal.js";
68
68
  import { buildSystemPrompt } from "../prompts/system-prompt.js";
69
69
  import type { ContentBlock, Message } from "../providers/types.js";
@@ -608,7 +608,7 @@ export class Conversation {
608
608
  resolveTools,
609
609
  resolveSystemPrompt: resolveSystemPromptCallback,
610
610
  });
611
- this.contextWindowManager = new ContextWindowManager({
611
+ this.contextWindowManager = createContextWindowManager({
612
612
  provider,
613
613
  systemPrompt: () => resolveSystemPromptCallback([]).systemPrompt,
614
614
  config: initialContextWindowConfig,
@@ -1060,9 +1060,7 @@ export class Conversation {
1060
1060
  );
1061
1061
  }
1062
1062
  this.messages = [...messages];
1063
- this.contextWindowManager.nonPersistedPrefixCount = messages.length;
1064
- this.contextWindowManager.summaryIsInjected =
1065
- getSummaryFromContextMessage(messages[0]) != null;
1063
+ this.contextWindowManager.seedNonPersistedPrefix(messages.length);
1066
1064
  }
1067
1065
 
1068
1066
  /**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-conversation {@link ContextWindowManager} ownership for the default
3
+ * compaction module.
4
+ *
5
+ * Construction of the manager lives here rather than in the conversation so the
6
+ * compaction surface owns the manager's lifetime: {@link createContextWindowManager}
7
+ * builds the instance and records it in a per-conversation store, and
8
+ * {@link disposeContextWindowManager} drops it when the conversation is torn
9
+ * down so the store doesn't grow unbounded.
10
+ *
11
+ * This module is side-effect free: importing it only initializes an empty
12
+ * store and registers no plugin.
13
+ */
14
+
15
+ import {
16
+ ContextWindowManager,
17
+ type ContextWindowManagerOptions,
18
+ } from "../../../context/window-manager.js";
19
+
20
+ /** Live managers keyed by conversation id. */
21
+ const managersByConversation = new Map<string, ContextWindowManager>();
22
+
23
+ /**
24
+ * Build the conversation's {@link ContextWindowManager} and register it under
25
+ * its conversation id. Construction is owned by the compaction module; the
26
+ * conversation supplies the provider, prompt/tool resolvers, and initial
27
+ * config. Replaces any prior manager for the same conversation so each
28
+ * construction yields a fresh instance, matching a direct `new` call.
29
+ */
30
+ export function createContextWindowManager(
31
+ options: ContextWindowManagerOptions & { conversationId: string },
32
+ ): ContextWindowManager {
33
+ const manager = new ContextWindowManager(options);
34
+ managersByConversation.set(options.conversationId, manager);
35
+ return manager;
36
+ }
37
+
38
+ /**
39
+ * Release the manager registered for a conversation. Called from conversation
40
+ * teardown so the store releases the instance once the conversation is gone.
41
+ */
42
+ export function disposeContextWindowManager(conversationId: string): void {
43
+ managersByConversation.delete(conversationId);
44
+ }
@@ -21,40 +21,62 @@
21
21
  * The memory graph handle is sourced internally from the plugin's own
22
22
  * conversation-keyed registry ({@link getLiveGraphMemory}) rather than being
23
23
  * threaded in by the loop — it is memory-retrieval-specific state, not
24
- * something the generic loop or the shared {@link TurnContext} should carry.
24
+ * something the generic loop should carry.
25
25
  */
26
26
 
27
27
  import {
28
28
  applyRuntimeInjections,
29
29
  type InboundActorContext,
30
30
  type InjectionMode,
31
- type RuntimeInjectionOptions,
32
31
  type RuntimeInjectionResult,
33
32
  } from "../../../../daemon/conversation-runtime-assembly.js";
34
- import { resolveTrustClass } from "../../../../daemon/trust-context.js";
33
+ import {
34
+ resolveTrustClass,
35
+ type TrustContext,
36
+ } from "../../../../daemon/trust-context.js";
35
37
  import { stripHistoricalWebSearchResults } from "../../../../daemon/web-search-history.js";
36
38
  import { getLiveGraphMemory } from "../../../../memory/graph/conversation-graph-memory.js";
37
39
  import type { Message } from "../../../../providers/types.js";
38
40
  import { getLogger } from "../../../../util/logger.js";
39
- import type { TurnContext } from "../../../types.js";
40
41
 
41
42
  const log = getLogger("post-compact-reinject");
42
43
 
43
44
  /**
44
- * The slice of the hook's context the agent loop supplies from its own working
45
- * state. Re-injection inputs migrate loop-ward by growing this type; the loop
46
- * hands the hook an object of this shape when it calls
45
+ * Everything the post-compaction hook needs, supplied by the agent loop from
46
+ * its own working state. Re-injection inputs migrate loop-ward by growing this
47
+ * type; the loop hands the hook an object of this shape when it calls
47
48
  * {@link postCompactReinject} directly.
49
+ *
50
+ * The turn-identity fields are flat here: `conversationId` is the key the
51
+ * re-injection resolves the live conversation through (and the only one that,
52
+ * together with `requestId` and `trust`, cannot be recovered from that live
53
+ * conversation), while `turnIndex` and `callSite` are intentionally omitted so
54
+ * {@link applyRuntimeInjections} self-resolves them from the live conversation.
55
+ * The memory graph handle is likewise sourced internally via
56
+ * {@link getLiveGraphMemory} rather than threaded in.
48
57
  */
49
58
  export interface PostCompactionHookInput {
50
59
  /** Compacted message history to re-inject onto. */
51
60
  history: Message[];
52
61
  /**
53
- * Per-turn conversation context forwarded to the injector chain. Its
54
- * `conversationId` is the key the re-injection resolves the live
55
- * conversation through, so the loop always supplies it.
62
+ * Stable ID for the current request, forwarded onto the injector turn
63
+ * context. The one turn-identity field that cannot be recovered from the
64
+ * live conversation, so the loop supplies it.
65
+ */
66
+ requestId: string | undefined;
67
+ /**
68
+ * Conversation the turn is scoped to. The key the re-injection resolves the
69
+ * live conversation (and its channel/trust/transcript state) through, so the
70
+ * loop always supplies it.
71
+ */
72
+ conversationId: string;
73
+ /**
74
+ * Trust classification and channel identity for the inbound actor, forwarded
75
+ * onto the injector turn context and used to gate the memory-graph re-track.
76
+ * Supplied by the loop as the turn-start snapshot rather than re-resolved
77
+ * mid-turn.
56
78
  */
57
- turnContext: TurnContext;
79
+ trust: TrustContext;
58
80
  /**
59
81
  * Whether the in-flight turn has no human present to answer clarification
60
82
  * questions. Resolved once by the agent loop at turn start (from its
@@ -91,82 +113,40 @@ export interface PostCompactionHookInput {
91
113
  actorContext: InboundActorContext | null;
92
114
  }
93
115
 
94
- /**
95
- * Everything the hook needs in a single context: the loop-supplied
96
- * {@link PostCompactionHookInput} plus the non-identity
97
- * {@link RuntimeInjectionOptions} (spread top-level so each field stays
98
- * individually addressable). The turn-identity fields (`requestId`,
99
- * `conversationId`, `turnIndex`, `trust`, `callSite`) are omitted from the
100
- * inherited options because the hook sources them from
101
- * {@link PostCompactionHookInput.turnContext}; the memory graph handle is
102
- * likewise sourced internally via {@link getLiveGraphMemory} rather than
103
- * threaded in.
104
- */
105
- export interface PostCompactContext
106
- extends
107
- Omit<
108
- RuntimeInjectionOptions,
109
- "requestId" | "conversationId" | "turnIndex" | "trust" | "callSite"
110
- >,
111
- PostCompactionHookInput {
112
- /**
113
- * Re-declared to reconcile the optional {@link RuntimeInjectionOptions} field
114
- * with the required {@link PostCompactionHookInput} one: the hook always
115
- * receives this from the loop, and it flows into {@link applyRuntimeInjections}
116
- * via the spread options.
117
- */
118
- isNonInteractive: boolean;
119
- /**
120
- * Re-declared for the same reason as {@link isNonInteractive}: required on
121
- * {@link PostCompactionHookInput} but optional on {@link RuntimeInjectionOptions}.
122
- */
123
- mode: InjectionMode;
124
- /**
125
- * Re-declared for the same reason as {@link isNonInteractive}: required on
126
- * {@link PostCompactionHookInput} but optional on {@link RuntimeInjectionOptions}.
127
- */
128
- modelProfile: string | null;
129
- /**
130
- * Re-declared for the same reason as {@link isNonInteractive}: required on
131
- * {@link PostCompactionHookInput} but optional on {@link RuntimeInjectionOptions}.
132
- */
133
- actorContext: InboundActorContext | null;
134
- }
135
-
136
116
  export default async function postCompactReinject(
137
- ctx: PostCompactContext,
117
+ ctx: PostCompactionHookInput,
138
118
  ): Promise<RuntimeInjectionResult> {
139
- const { history, turnContext, ...options } = ctx;
140
- // The loop hands its canonical per-turn context as a single `turnContext`;
141
- // unnest its turn-identity fields onto the flat injection options so the
142
- // re-injection resolves the same live conversation (and its channel/trust/
143
- // transcript state) as the turn's initial assembly.
119
+ const { history, requestId, conversationId, trust, ...options } = ctx;
120
+ // The remaining `options` carry the non-identity injection inputs
121
+ // (`isNonInteractive`, `mode`, `modelProfile`, `actorContext`). Forward the
122
+ // turn-identity fields the live conversation can't supply (`requestId`,
123
+ // `conversationId`, `trust`); `turnIndex` and `callSite` self-resolve from
124
+ // the live conversation so the re-injection matches the turn's initial
125
+ // assembly.
144
126
  const result = await applyRuntimeInjections(history, {
145
127
  ...options,
146
- requestId: turnContext.requestId,
147
- conversationId: turnContext.conversationId,
148
- turnIndex: turnContext.turnIndex,
149
- trust: turnContext.trust,
150
- callSite: turnContext.callSite,
128
+ requestId,
129
+ conversationId,
130
+ trust,
151
131
  });
152
132
  // Re-track the nodes the memory graph last injected so they survive against
153
133
  // the re-injected history. Untrusted actors and minimal-mode turns never
154
134
  // received a memory-graph injection, so there is nothing to re-track. The
155
135
  // actor's trust class is derived from the turn's own trust context (the same
156
- // value the injector chain resolves), not threaded in from the loop. The
157
- // live graph handle is looked up from the plugin's own registry by the
158
- // turn's conversation id — the same instance the turn's retrieval mutated,
159
- // so re-tracking sees the real cached-node state.
160
- const isTrustedActor = resolveTrustClass(turnContext.trust) === "guardian";
136
+ // value the injector chain resolves). The live graph handle is looked up
137
+ // from the plugin's own registry by the turn's conversation id — the same
138
+ // instance the turn's retrieval mutated, so re-tracking sees the real
139
+ // cached-node state.
140
+ const isTrustedActor = resolveTrustClass(trust) === "guardian";
161
141
  if (isTrustedActor && options.mode !== "minimal") {
162
- getLiveGraphMemory(turnContext.conversationId)?.retrackCachedNodes();
142
+ getLiveGraphMemory(conversationId)?.retrackCachedNodes();
163
143
  }
164
144
  const strip = stripHistoricalWebSearchResults(result.messages);
165
145
  if (strip.stats.blocksStripped > 0) {
166
146
  log.info(
167
147
  {
168
148
  phase: "mid-loop-compact",
169
- conversationId: turnContext.conversationId,
149
+ conversationId,
170
150
  ...strip.stats,
171
151
  },
172
152
  "Converted historical web_search_tool_result blocks to text summaries",
@@ -47,7 +47,7 @@ interface WakeConversationProbe {
47
47
  runCalls: Array<{
48
48
  input: Message[];
49
49
  requestId?: string;
50
- turnContext?: unknown;
50
+ trust?: unknown;
51
51
  allowedTools?: string[];
52
52
  order: number;
53
53
  }>;
@@ -369,7 +369,7 @@ function makeWakeConversation(options: {
369
369
  probe.runCalls.push({
370
370
  input: [...input],
371
371
  requestId: runOptions?.requestId,
372
- turnContext: runOptions?.turnContext,
372
+ trust: runOptions?.trust,
373
373
  allowedTools: snapshotAllowedTools(),
374
374
  order: order++,
375
375
  });
@@ -520,7 +520,7 @@ describe("wakeAgentForOpportunity", () => {
520
520
  expect(conversation.runCalls).toHaveLength(0);
521
521
  });
522
522
 
523
- test("builds a guardian turn context for explicit local-owner cleanup-mode wakes", async () => {
523
+ test("forwards a guardian trust snapshot for explicit local-owner cleanup-mode wakes", async () => {
524
524
  mockDiskPressureStatus = {
525
525
  enabled: true,
526
526
  state: "critical",
@@ -551,11 +551,9 @@ describe("wakeAgentForOpportunity", () => {
551
551
 
552
552
  expect(result).toEqual({ invoked: true, producedToolCalls: false });
553
553
  expect(conversation.runCalls).toHaveLength(1);
554
- expect(conversation.runCalls[0]!.turnContext).toEqual({
555
- requestId: "wake:local-cleanup",
556
- conversationId: conversation.conversationId,
557
- turnIndex: 0,
558
- trust: { sourceChannel: "vellum", trustClass: "guardian" },
554
+ expect(conversation.runCalls[0]!.trust).toEqual({
555
+ sourceChannel: "vellum",
556
+ trustClass: "guardian",
559
557
  });
560
558
  });
561
559
 
@@ -69,7 +69,6 @@ import {
69
69
  recordRequestLog,
70
70
  setAgentLoopExitReasonOnLatestLog,
71
71
  } from "../memory/llm-request-log-store.js";
72
- import type { TurnContext } from "../plugins/types.js";
73
72
  import type { Message } from "../providers/types.js";
74
73
  import { getLogger } from "../util/logger.js";
75
74
 
@@ -327,22 +326,24 @@ function classifyWakeDiskPressurePolicy(opts: WakeOptions): {
327
326
  return { decision, status };
328
327
  }
329
328
 
330
- function buildWakeTurnContext(
329
+ /**
330
+ * Trust snapshot the wake hands to the agent loop. Only needed on the
331
+ * disk-pressure cleanup-mode path, whose guardian fallback lets the cleanup
332
+ * turn clear the side-effect approval gate; other wakes leave it unset (the
333
+ * loop has no compaction path during a wake, so trust is otherwise unread).
334
+ */
335
+ function buildWakeTrust(
331
336
  opts: WakeOptions,
332
337
  decision: DiskPressureTurnPolicyDecision,
333
- ): TurnContext | undefined {
338
+ ): TrustContext | undefined {
334
339
  if (decision.action !== "allow-cleanup-mode") return undefined;
335
- return {
336
- requestId: `wake:${opts.source}`,
337
- conversationId: opts.conversationId,
338
- turnIndex: 0,
339
- trust:
340
- opts.trustContext ??
341
- ({
342
- sourceChannel: opts.sourceChannel ?? "vellum",
343
- trustClass: "guardian",
344
- } satisfies TrustContext),
345
- };
340
+ return (
341
+ opts.trustContext ??
342
+ ({
343
+ sourceChannel: opts.sourceChannel ?? "vellum",
344
+ trustClass: "guardian",
345
+ } satisfies TrustContext)
346
+ );
346
347
  }
347
348
 
348
349
  /**
@@ -482,7 +483,7 @@ export async function wakeAgentForOpportunity(
482
483
  // post-run would therefore include the tail we just pushed and the
483
484
  // tail-slice math would skip every message.
484
485
  const baselineLength = baseline.length;
485
- const wakeTurnContext = buildWakeTurnContext(opts, diskPressureDecision);
486
+ const wakeTrust = buildWakeTrust(opts, diskPressureDecision);
486
487
  // Build the hint injection. Three modes:
487
488
  // - `skipHintInjection`: caller has already persisted an instruction
488
489
  // message into the conversation history (typical for fork-based
@@ -838,7 +839,7 @@ export async function wakeAgentForOpportunity(
838
839
  // short-circuit and silently drop both per-callsite config and the
839
840
  // pinned `overrideProfile` below.
840
841
  callSite,
841
- turnContext: wakeTurnContext,
842
+ trust: wakeTrust,
842
843
  overrideProfile,
843
844
  // Wake runs have no orchestrator-side mid-loop compaction path,
844
845
  // so the budget gate stays disabled (`overflowRecovery.enabled =