@vellumai/assistant 0.8.8-dev.202606071441.cfe7f13 → 0.8.8-dev.202606071734.db66b83

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.202606071441.cfe7f13",
3
+ "version": "0.8.8-dev.202606071734.db66b83",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -810,7 +810,7 @@ describe("session-agent-loop", () => {
810
810
  });
811
811
  });
812
812
 
813
- test("freezes the timestamp and client timezone snapshot on the conversation, not the options bag", async () => {
813
+ test("freezes the client timezone snapshot on the conversation, not the options bag", async () => {
814
814
  mockUiConfig = {
815
815
  userTimezone: "US/Eastern",
816
816
  detectedTimezone: "US/Central",
@@ -827,24 +827,19 @@ describe("session-agent-loop", () => {
827
827
 
828
828
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
829
829
 
830
- // The loop resolves the effective timezone to format the turn timestamp.
831
- expect(formatTurnTimestampMock).toHaveBeenCalledWith({
832
- timeZone: "America/New_York",
833
- });
834
- // The formatted timestamp, the turn-start client timezone, and the
835
- // long-absence gap are frozen on the conversation as
836
- // `currentTurnTemporalSnapshot` so every post-compaction re-injection
837
- // reuses the same instant; the live `clientTimezone` would otherwise be
838
- // clobbered mid-turn.
830
+ // The turn-start client timezone and the long-absence gap are frozen on
831
+ // the conversation as `currentTurnTemporalSnapshot`; the live
832
+ // `clientTimezone` would otherwise be clobbered mid-turn. `current_time`
833
+ // is not frozen — it is computed fresh at each injection inside
834
+ // `applyRuntimeInjections`.
839
835
  expect(ctx.currentTurnTemporalSnapshot).toEqual({
840
- timestamp: "2026-01-01 (Thursday) 00:00:00 +00:00 (UTC)",
841
836
  clientTimezone: "America/Los_Angeles",
842
837
  timeSinceLastMessage: null,
843
838
  });
844
- // Neither the timestamp, the timezones, nor the long-absence gap are
845
- // threaded through the options bag — `applyRuntimeInjections` sources the
846
- // timestamp, client timezone, and gap from the snapshot and the config
847
- // timezones from config (`ui.userTimezone`, `ui.detectedTimezone`).
839
+ // Neither the timezones nor the long-absence gap are threaded through the
840
+ // options bag — `applyRuntimeInjections` sources the client timezone and
841
+ // gap from the snapshot and the config timezones from config
842
+ // (`ui.userTimezone`, `ui.detectedTimezone`).
848
843
  const injectionOptions = applyRuntimeInjectionsMock.mock.calls[0]?.[1];
849
844
  expect(injectionOptions).not.toHaveProperty("timestamp");
850
845
  expect(injectionOptions).not.toHaveProperty("clientTimezone");
@@ -48,6 +48,18 @@ mock.module("../memory/pkb/pkb-search.js", () => ({
48
48
  },
49
49
  }));
50
50
 
51
+ // `applyRuntimeInjections` computes the `<turn_context>` `current_time` live via
52
+ // `formatTurnTimestamp`; pin it so the rendered block matches the deterministic
53
+ // `buildUnifiedTurnContextBlock` expectations below. The rest of the module
54
+ // (timezone canonicalization/resolution) keeps its real behavior.
55
+ const FIXED_TURN_TIMESTAMP = "2026-04-02T12:00:00Z";
56
+ const realDateContextForAssemblyTest =
57
+ await import("../daemon/date-context.js");
58
+ mock.module("../daemon/date-context.js", () => ({
59
+ ...realDateContextForAssemblyTest,
60
+ formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
61
+ }));
62
+
51
63
  import {
52
64
  clearConversations,
53
65
  setConversation,
@@ -148,7 +160,6 @@ function seedActiveSurfaceConversation(
148
160
  channelCapabilities?: ChannelCapabilities,
149
161
  commandIntent?: { type: string; payload?: string; languageCode?: string },
150
162
  currentTurnTemporalSnapshot?: {
151
- timestamp: string;
152
163
  clientTimezone: string | null;
153
164
  },
154
165
  ): void {
@@ -886,7 +897,6 @@ describe("applyRuntimeInjections — injection mode", () => {
886
897
  channelCapabilities,
887
898
  { type: "start" },
888
899
  {
889
- timestamp: "2026-03-04 (Tuesday) 12:00:00 +00:00 (UTC)",
890
900
  clientTimezone: null,
891
901
  },
892
902
  );
@@ -1806,20 +1816,19 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1806
1816
  },
1807
1817
  ];
1808
1818
 
1809
- const sampleTimestamp = "2026-04-02T12:00:00Z";
1810
1819
  const sampleOptions = {
1811
1820
  interfaceName: "macos",
1812
1821
  };
1813
- // The block reflects both the options-bag interface and the timestamp the
1814
- // loop freezes on the conversation; assembly merges the two.
1822
+ // The block reflects the options-bag interface and the `current_time` the
1823
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
1815
1824
  const sampleBlock = buildUnifiedTurnContextBlock({
1816
- timestamp: sampleTimestamp,
1825
+ timestamp: FIXED_TURN_TIMESTAMP,
1817
1826
  ...sampleOptions,
1818
1827
  });
1819
1828
 
1820
1829
  // Seed the live fallback conversation with the per-turn temporal snapshot
1821
- // (so `applyRuntimeInjections` sources the `<turn_context>` timestamp from it)
1822
- // and the turn interface context (so it sources the interface label to match
1830
+ // (its presence gates the `<turn_context>` block) and the turn interface
1831
+ // context (so the injector sources the interface label to match
1823
1832
  // `sampleOptions.interfaceName`).
1824
1833
  function seedTemporalSnapshot(): void {
1825
1834
  setConversation("runtime-assembly-fallback", {
@@ -1829,7 +1838,6 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
1829
1838
  workspaceTopLevelDirty: false,
1830
1839
  surfaceState: new Map(),
1831
1840
  currentTurnTemporalSnapshot: {
1832
- timestamp: sampleTimestamp,
1833
1841
  clientTimezone: null,
1834
1842
  },
1835
1843
  currentTurnInterfaceContext: {
@@ -1910,13 +1918,10 @@ describe("applyRuntimeInjections timezone resolution", () => {
1910
1918
  { role: "user", content: [{ type: "text", text: "Hello there" }] },
1911
1919
  ];
1912
1920
 
1913
- const sampleTimestamp =
1914
- "2026-04-02 (Thursday) 08:00:00 -04:00 (America/New_York)";
1915
-
1916
1921
  // Seed the live fallback conversation with the per-turn temporal snapshot the
1917
1922
  // loop freezes after memory retrieval. `applyRuntimeInjections` sources the
1918
- // `<turn_context>` timestamp, the client timezone, and the long-absence gap
1919
- // from it, so the turn-context block only emits when this snapshot is present.
1923
+ // client timezone and the long-absence gap from it, so the turn-context block
1924
+ // only emits when this snapshot is present.
1920
1925
  function seedTemporalSnapshot(
1921
1926
  clientTimezone: string | null,
1922
1927
  timeSinceLastMessage: string | null = null,
@@ -1928,7 +1933,6 @@ describe("applyRuntimeInjections timezone resolution", () => {
1928
1933
  workspaceTopLevelDirty: false,
1929
1934
  surfaceState: new Map(),
1930
1935
  currentTurnTemporalSnapshot: {
1931
- timestamp: sampleTimestamp,
1932
1936
  clientTimezone,
1933
1937
  timeSinceLastMessage,
1934
1938
  },
@@ -2058,20 +2062,19 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
2058
2062
  },
2059
2063
  ];
2060
2064
 
2061
- const sampleTimestamp = "2026-04-02T12:00:00Z";
2062
2065
  const sampleOptions = {
2063
2066
  interfaceName: "macos",
2064
2067
  };
2065
- // The block reflects both the options-bag interface and the timestamp the
2066
- // loop freezes on the conversation; assembly merges the two.
2068
+ // The block reflects the options-bag interface and the `current_time` the
2069
+ // injector computes live (pinned to `FIXED_TURN_TIMESTAMP` for this suite).
2067
2070
  const sampleBlock = buildUnifiedTurnContextBlock({
2068
- timestamp: sampleTimestamp,
2071
+ timestamp: FIXED_TURN_TIMESTAMP,
2069
2072
  ...sampleOptions,
2070
2073
  });
2071
2074
 
2072
2075
  // Seed the live fallback conversation with the per-turn temporal snapshot
2073
- // (so `applyRuntimeInjections` sources the `<turn_context>` timestamp from it)
2074
- // and the turn interface context (so it sources the interface label to match
2076
+ // (its presence gates the `<turn_context>` block) and the turn interface
2077
+ // context (so the injector sources the interface label to match
2075
2078
  // `sampleOptions.interfaceName`).
2076
2079
  function seedTemporalSnapshot(): void {
2077
2080
  setConversation("runtime-assembly-fallback", {
@@ -2081,7 +2084,6 @@ describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
2081
2084
  workspaceTopLevelDirty: false,
2082
2085
  surfaceState: new Map(),
2083
2086
  currentTurnTemporalSnapshot: {
2084
- timestamp: sampleTimestamp,
2085
2087
  clientTimezone: null,
2086
2088
  },
2087
2089
  currentTurnInterfaceContext: {
@@ -38,6 +38,17 @@ mock.module("../config/loader.js", () => ({
38
38
  },
39
39
  }));
40
40
 
41
+ // `applyRuntimeInjections` computes the `<turn_context>` `current_time` live
42
+ // via `formatTurnTimestamp`; pin it so the rendered block matches the
43
+ // deterministic `buildUnifiedTurnContextBlock` expectations below. The rest of
44
+ // the module (timezone canonicalization/resolution) keeps its real behavior.
45
+ const FIXED_TURN_TIMESTAMP = "2026-04-22";
46
+ const realDateContext = await import("../daemon/date-context.js");
47
+ mock.module("../daemon/date-context.js", () => ({
48
+ ...realDateContext,
49
+ formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
50
+ }));
51
+
41
52
  const {
42
53
  applyRuntimeInjections,
43
54
  buildSubagentStatusBlock,
@@ -130,7 +141,6 @@ function clearNowScratchpad(): void {
130
141
  function seedWorkspaceContext(
131
142
  text: string,
132
143
  currentTurnTemporalSnapshot?: {
133
- timestamp: string;
134
144
  clientTimezone: string | null;
135
145
  },
136
146
  interfaceName?: string,
@@ -150,17 +160,18 @@ function seedWorkspaceContext(
150
160
  } as never);
151
161
  }
152
162
 
153
- // `applyRuntimeInjections` sources the `<turn_context>` timestamp from the live
154
- // conversation's frozen `currentTurnTemporalSnapshot`. Tests that drive the
155
- // chain without a caller-supplied `turnContext` fall back to the
156
- // runtime-assembly fallback conversation, so seed the snapshot there.
157
- function seedFallbackTemporalSnapshot(timestamp: string): void {
163
+ // `applyRuntimeInjections` gates the `<turn_context>` block on the live
164
+ // conversation's frozen `currentTurnTemporalSnapshot` (computing `current_time`
165
+ // live). Tests that drive the chain without a caller-supplied `turnContext`
166
+ // fall back to the runtime-assembly fallback conversation, so seed the snapshot
167
+ // there.
168
+ function seedFallbackTemporalSnapshot(): void {
158
169
  setConversation("runtime-assembly-fallback", {
159
170
  conversationId: "runtime-assembly-fallback",
160
171
  workingDir: "/sandbox",
161
172
  workspaceTopLevelContext: "",
162
173
  workspaceTopLevelDirty: false,
163
- currentTurnTemporalSnapshot: { timestamp, clientTimezone: null },
174
+ currentTurnTemporalSnapshot: { clientTimezone: null },
164
175
  } as never);
165
176
  }
166
177
 
@@ -387,10 +398,10 @@ describe("injector chain", () => {
387
398
  // unified-turn-context injector resolves the interface label to the `web`
388
399
  // default.
389
400
  const synthesizedBlock = buildUnifiedTurnContextBlock({
390
- timestamp: "synthesized",
401
+ timestamp: FIXED_TURN_TIMESTAMP,
391
402
  interfaceName: "web",
392
403
  });
393
- seedFallbackTemporalSnapshot("synthesized");
404
+ seedFallbackTemporalSnapshot();
394
405
  const result = await applyRuntimeInjections(runMessages, {});
395
406
 
396
407
  // The unified-turn-context injector fires even without a caller-supplied
@@ -429,9 +440,9 @@ describe("injector chain", () => {
429
440
  const workspaceText =
430
441
  "<workspace>\nRoot: /sandbox\nDirectories: src, lib\n</workspace>";
431
442
  // The interface label flows through the options bag; the timestamp is
432
- // frozen on the conversation's temporal snapshot.
443
+ // computed live at injection time.
433
444
  const unifiedTurn = buildUnifiedTurnContextBlock({
434
- timestamp: "2026-04-22",
445
+ timestamp: FIXED_TURN_TIMESTAMP,
435
446
  interfaceName: "macos",
436
447
  });
437
448
  // A completed child renders deterministically (no elapsed clock), so the
@@ -440,11 +451,7 @@ describe("injector chain", () => {
440
451
  seedSubagentChild(TEST_CONVERSATION_ID, subagentChild);
441
452
  const subagentBlock = buildSubagentStatusBlock([subagentChild])!;
442
453
 
443
- seedWorkspaceContext(
444
- workspaceText,
445
- { timestamp: "2026-04-22", clientTimezone: null },
446
- "macos",
447
- );
454
+ seedWorkspaceContext(workspaceText, { clientTimezone: null }, "macos");
448
455
  const result = await applyRuntimeInjections(runMessages, {
449
456
  turnContext: makeTurnContext(),
450
457
  });
@@ -571,10 +578,10 @@ describe("injector chain", () => {
571
578
  // `web` default. A live child subagent is seeded so the subagent-status
572
579
  // injector has a block to skip.
573
580
  const minimalTurnBlock = buildUnifiedTurnContextBlock({
574
- timestamp: "2026-04-22",
581
+ timestamp: FIXED_TURN_TIMESTAMP,
575
582
  interfaceName: "web",
576
583
  });
577
- seedWorkspaceContext("", { timestamp: "2026-04-22", clientTimezone: null });
584
+ seedWorkspaceContext("", { clientTimezone: null });
578
585
  seedSubagentChild(
579
586
  TEST_CONVERSATION_ID,
580
587
  makeSubagentState("sub-1", "worker", "running"),
@@ -1,7 +1,19 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { eq } from "drizzle-orm";
4
4
 
5
+ import * as dateContext from "../daemon/date-context.js";
6
+
7
+ // `applyRuntimeInjections` computes the `<turn_context>` `current_time` live via
8
+ // `formatTurnTimestamp`; pin it so the rendered block matches the deterministic
9
+ // `buildUnifiedTurnContextBlock` expectation below. The rest of the module
10
+ // (timezone canonicalization/resolution) keeps its real behavior.
11
+ const FIXED_TURN_TIMESTAMP = "2026-04-02T12:00:00Z";
12
+ mock.module("../daemon/date-context.js", () => ({
13
+ ...dateContext,
14
+ formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
15
+ }));
16
+
5
17
  import {
6
18
  clearConversations,
7
19
  setConversation,
@@ -84,7 +96,6 @@ let liveConversation: {
84
96
  channelCapabilities?: ChannelCapabilities;
85
97
  trustContext?: { trustClass: string };
86
98
  currentTurnTemporalSnapshot?: {
87
- timestamp: string;
88
99
  clientTimezone: string | null;
89
100
  };
90
101
  currentTurnInterfaceContext?: {
@@ -110,12 +121,11 @@ function resetLiveConversation(): void {
110
121
  };
111
122
  }
112
123
 
113
- // `applyRuntimeInjections` sources the `<turn_context>` timestamp from the live
114
- // conversation's frozen temporal snapshot, so seed it for tests that assert the
115
- // unified-turn-context block is present.
116
- function seedTemporalSnapshot(timestamp: string): void {
124
+ // `applyRuntimeInjections` gates the `<turn_context>` block on the live
125
+ // conversation's frozen temporal snapshot (computing `current_time` live), so
126
+ // seed it for tests that assert the unified-turn-context block is present.
127
+ function seedTemporalSnapshot(): void {
117
128
  liveConversation.currentTurnTemporalSnapshot = {
118
- timestamp,
119
129
  clientTimezone: null,
120
130
  };
121
131
  setConversation(TEST_CONVERSATION_ID, liveConversation as never);
@@ -240,7 +250,7 @@ Do not work on unrelated tasks until enough space is freed to clear the lock or
240
250
 
241
251
  seedWorkspaceContext(workspace);
242
252
  seedDiskPressure(true);
243
- seedTemporalSnapshot("2026-04-02T12:00:00Z");
253
+ seedTemporalSnapshot();
244
254
  const result = await applyRuntimeInjections(runMessages, {
245
255
  turnContext: makeContext(),
246
256
  });
@@ -264,7 +274,7 @@ Do not work on unrelated tasks until enough space is freed to clear the lock or
264
274
  interfaceName: "macos",
265
275
  });
266
276
  seedDiskPressure(true);
267
- seedTemporalSnapshot("2026-04-02T12:00:00Z");
277
+ seedTemporalSnapshot();
268
278
  const result = await applyRuntimeInjections(
269
279
  [{ role: "user", content: [{ type: "text", text: "status" }] }],
270
280
  {
@@ -34,19 +34,19 @@ describe("buildShimSource", () => {
34
34
  test("emits a globalThis trampoline + one binding per export", () => {
35
35
  const source = buildShimSource(
36
36
  ["foo", "bar"],
37
- Symbol.for("vellum.plugin-api.v1"),
37
+ Symbol.for("vellum.plugin-api"),
38
38
  );
39
39
  expect(source).toBe(
40
- `const api = globalThis[Symbol.for("vellum.plugin-api.v1")];\n` +
40
+ `const api = globalThis[Symbol.for("vellum.plugin-api")];\n` +
41
41
  `export const foo = api.foo;\n` +
42
42
  `export const bar = api.bar;\n`,
43
43
  );
44
44
  });
45
45
 
46
46
  test("handles an empty export list (today's types-only surface)", () => {
47
- const source = buildShimSource([], Symbol.for("vellum.plugin-api.v1"));
47
+ const source = buildShimSource([], Symbol.for("vellum.plugin-api"));
48
48
  expect(source).toBe(
49
- `const api = globalThis[Symbol.for("vellum.plugin-api.v1")];\n`,
49
+ `const api = globalThis[Symbol.for("vellum.plugin-api")];\n`,
50
50
  );
51
51
  });
52
52
  });
package/src/agent/loop.ts CHANGED
@@ -834,6 +834,12 @@ export class AgentLoop {
834
834
  let lastLlmCallTime = 0;
835
835
  let exitReason: ExitReason | null = null;
836
836
  let appendedNewMessages = false;
837
+ // Armed at the end of a tool-use iteration so the budget gate runs at the
838
+ // top of the NEXT iteration — before that iteration's provider call —
839
+ // instead of after the current one. The first call and stop-hook re-query
840
+ // continues re-enter without arming, so the gate fires on exactly the same
841
+ // occasions as the prior post-call placement.
842
+ let budgetGateArmed = false;
837
843
  const rlog = requestId ? log.child({ requestId }) : log;
838
844
 
839
845
  // Resolve the inference-profile override that applies right now. The
@@ -874,6 +880,89 @@ export class AgentLoop {
874
880
  let toolUseBlocks: Extract<ContentBlock, { type: "tool_use" }>[] = [];
875
881
 
876
882
  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
+ // ── Pre-call budget gate ─────────────────────────────────────
891
+ // When overflow recovery is enabled, estimate the running context
892
+ // size as it approaches the preflight budget before issuing the
893
+ // provider call. With `compactInPlace` the loop compacts in place and
894
+ // proceeds with the call; otherwise it yields (`exitReason =
895
+ // "budget"`) so the orchestrator can recover before the call risks a
896
+ // hard context-too-large rejection. Keyed off the loop's own
897
+ // `history.length` (the messages actually in context this turn,
898
+ // including tool iterations) rather than the durable conversation
899
+ // count. Gated on `budgetGateArmed` so it skips the first call and
900
+ // stop-hook re-query continues.
901
+ if (budgetGateArmed) {
902
+ budgetGateArmed = false;
903
+ const contextWindow = resolveContextWindow?.();
904
+ if (contextWindow?.overflowRecovery.enabled) {
905
+ const { maxInputTokens, overflowRecovery } = contextWindow;
906
+ const safetyMargin =
907
+ history.length > LONG_HISTORY_MESSAGE_THRESHOLD
908
+ ? Math.max(
909
+ overflowRecovery.safetyMarginRatio,
910
+ LONG_HISTORY_SAFETY_MARGIN_FLOOR,
911
+ )
912
+ : overflowRecovery.safetyMarginRatio;
913
+ const preflightBudget = Math.floor(
914
+ maxInputTokens * (1 - safetyMargin),
915
+ );
916
+ const midLoopThreshold =
917
+ preflightBudget * MID_LOOP_YIELD_THRESHOLD_RATIO;
918
+ const estimated = this.estimateTokens(history);
919
+ if (estimated > midLoopThreshold) {
920
+ let compactedInPlace = false;
921
+ if (compactInPlace) {
922
+ rlog.info(
923
+ {
924
+ phase: "mid-loop",
925
+ estimated,
926
+ threshold: midLoopThreshold,
927
+ },
928
+ "Token estimate approaching budget — compacting in place",
929
+ );
930
+ const compacted = await this.compact(
931
+ history,
932
+ turnCtx,
933
+ contextWindowManager,
934
+ signal,
935
+ onEvent,
936
+ resolveEffectiveOverrideProfile() ?? null,
937
+ isNonInteractive,
938
+ modelProfile,
939
+ actorContext,
940
+ );
941
+ if (compacted) {
942
+ history = compacted;
943
+ // The compacted, re-injected array is the new base; output
944
+ // produced after this point is what the orchestrator
945
+ // persists.
946
+ newMessagesStart = history.length;
947
+ compactedInPlace = true;
948
+ }
949
+ }
950
+ if (!compactedInPlace) {
951
+ rlog.warn(
952
+ {
953
+ phase: "mid-loop",
954
+ estimated,
955
+ threshold: midLoopThreshold,
956
+ },
957
+ "Token estimate approaching budget — yielding for compaction",
958
+ );
959
+ exitReason = "budget";
960
+ break;
961
+ }
962
+ }
963
+ }
964
+ }
965
+
877
966
  // Resolve tools for this turn: use the dynamic resolver if provided,
878
967
  // otherwise fall back to the static tool list.
879
968
  const currentTools = this.resolveTools
@@ -1095,13 +1184,6 @@ export class AgentLoop {
1095
1184
  signal,
1096
1185
  };
1097
1186
 
1098
- // Per-turn pipeline context. Real call sites thread a full
1099
- // `turnContext` into `run()` and it is used directly; standalone
1100
- // unit-test instantiations that never plumb a context through fall
1101
- // back to a synthesized placeholder scoped to the tool-use iteration.
1102
- const turnCtx =
1103
- turnContext ?? buildLoopTurnContext(requestId, toolUseTurns);
1104
-
1105
1187
  // Let plugins edit the outbound request and opt this call into deferred
1106
1188
  // output streaming. Runs for every provider call; hooks self-gate on
1107
1189
  // call site / conversation. Fail-open: a throwing hook leaves the
@@ -1611,8 +1693,9 @@ export class AgentLoop {
1611
1693
  history.push({ role: "user", content: resultBlocks });
1612
1694
 
1613
1695
  // Invoke checkpoint callback after tool results are in history.
1614
- // Handoff is offered first so a queued message takes precedence over
1615
- // the mid-loop budget yield below.
1696
+ // Handoff takes precedence over the budget gate: a handoff decision
1697
+ // breaks here and leaves `budgetGateArmed` false, so a queued message
1698
+ // is processed before the next iteration's pre-call budget gate.
1616
1699
  if (onCheckpoint) {
1617
1700
  const decision = await onCheckpoint({
1618
1701
  turnIndex: toolUseTurns - 1, // 0-based (toolUseTurns was already incremented)
@@ -1626,64 +1709,11 @@ export class AgentLoop {
1626
1709
  }
1627
1710
  }
1628
1711
 
1629
- // Mid-loop budget gate: when overflow recovery is enabled, estimate
1630
- // the running context size as it approaches the preflight budget.
1631
- // With `compactInPlace` the loop compacts in place and continues;
1632
- // without it it yields (`exitReason = "budget"`) so the orchestrator
1633
- // can recover before the next provider call risks a hard
1634
- // context-too-large rejection. Keyed off the loop's own
1635
- // `history.length` (the messages actually in context this turn,
1636
- // including tool iterations) rather than the durable conversation
1637
- // count.
1638
- const contextWindow = resolveContextWindow?.();
1639
- if (contextWindow?.overflowRecovery.enabled) {
1640
- const { maxInputTokens, overflowRecovery } = contextWindow;
1641
- const safetyMargin =
1642
- history.length > LONG_HISTORY_MESSAGE_THRESHOLD
1643
- ? Math.max(
1644
- overflowRecovery.safetyMarginRatio,
1645
- LONG_HISTORY_SAFETY_MARGIN_FLOOR,
1646
- )
1647
- : overflowRecovery.safetyMarginRatio;
1648
- const preflightBudget = Math.floor(
1649
- maxInputTokens * (1 - safetyMargin),
1650
- );
1651
- const midLoopThreshold =
1652
- preflightBudget * MID_LOOP_YIELD_THRESHOLD_RATIO;
1653
- const estimated = this.estimateTokens(history);
1654
- if (estimated > midLoopThreshold) {
1655
- if (compactInPlace) {
1656
- rlog.info(
1657
- { phase: "mid-loop", estimated, threshold: midLoopThreshold },
1658
- "Token estimate approaching budget — compacting in place",
1659
- );
1660
- const compacted = await this.compact(
1661
- history,
1662
- turnCtx,
1663
- contextWindowManager,
1664
- signal,
1665
- onEvent,
1666
- resolveEffectiveOverrideProfile() ?? null,
1667
- isNonInteractive,
1668
- modelProfile,
1669
- actorContext,
1670
- );
1671
- if (compacted) {
1672
- history = compacted;
1673
- // The compacted, re-injected array is the new base; output
1674
- // produced after this point is what the orchestrator persists.
1675
- newMessagesStart = history.length;
1676
- continue;
1677
- }
1678
- }
1679
- rlog.warn(
1680
- { phase: "mid-loop", estimated, threshold: midLoopThreshold },
1681
- "Token estimate approaching budget — yielding for compaction",
1682
- );
1683
- exitReason = "budget";
1684
- break;
1685
- }
1686
- }
1712
+ // Arm the pre-call budget gate for the next iteration. Placed after
1713
+ // the checkpoint so a handoff yield (which breaks above) leaves it
1714
+ // disarmed; the gate then runs at the top of the next iteration,
1715
+ // before that iteration's provider call.
1716
+ budgetGateArmed = true;
1687
1717
  } catch (error) {
1688
1718
  // Abort errors are expected when user cancels — synthesize
1689
1719
  // cancellation tool_results so the history stays valid for the
@@ -62,45 +62,51 @@ Examples:
62
62
  "--ref <ref>",
63
63
  `Git ref to fetch from (default: ${DEFAULT_PLUGIN_REF})`,
64
64
  )
65
- .action(async (name: string, opts: { force?: boolean; ref?: string }) => {
66
- try {
67
- const result = await installPlugin(
68
- {
69
- name,
70
- force: opts.force ?? false,
71
- ref: opts.ref ?? DEFAULT_PLUGIN_REF,
72
- },
73
- { fetch: globalThis.fetch.bind(globalThis) },
74
- );
75
- log.info(
76
- {
77
- name: result.name,
78
- target: result.target,
79
- fileCount: result.fileCount,
80
- ref: result.ref,
81
- },
82
- "external plugin installed",
83
- );
84
- console.log(
85
- `Installed plugin "${result.name}" (${result.fileCount} file${result.fileCount === 1 ? "" : "s"}) → ${result.target}`,
86
- );
87
- console.log("Restart the assistant to pick up the new plugin.");
88
- } catch (err) {
89
- if (err instanceof PluginAlreadyInstalledError) {
90
- console.error(`${err.message}\nPass --force to overwrite.`);
91
- process.exitCode = 1;
92
- return;
93
- }
94
- if (err instanceof PluginNotFoundError) {
95
- console.error(err.message);
65
+ .action(
66
+ async (name: string, opts: { force?: boolean; ref?: string }) => {
67
+ try {
68
+ const result = await installPlugin(
69
+ {
70
+ name,
71
+ force: opts.force ?? false,
72
+ ref: opts.ref ?? DEFAULT_PLUGIN_REF,
73
+ },
74
+ { fetch: globalThis.fetch.bind(globalThis) },
75
+ );
76
+ log.info(
77
+ {
78
+ name: result.name,
79
+ target: result.target,
80
+ fileCount: result.fileCount,
81
+ ref: result.ref,
82
+ commit: result.commit,
83
+ },
84
+ "external plugin installed",
85
+ );
86
+ const pinned = result.commit
87
+ ? ` at ${result.commit.slice(0, 7)}`
88
+ : "";
89
+ console.log(
90
+ `Installed plugin "${result.name}" (${result.fileCount} file${result.fileCount === 1 ? "" : "s"})${pinned} → ${result.target}`,
91
+ );
92
+ console.log("Restart the assistant to pick up the new plugin.");
93
+ } catch (err) {
94
+ if (err instanceof PluginAlreadyInstalledError) {
95
+ console.error(`${err.message}\nPass --force to overwrite.`);
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ if (err instanceof PluginNotFoundError) {
100
+ console.error(err.message);
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ console.error(`Plugin install failed: ${message}`);
96
106
  process.exitCode = 1;
97
- return;
98
107
  }
99
- const message = err instanceof Error ? err.message : String(err);
100
- console.error(`Plugin install failed: ${message}`);
101
- process.exitCode = 1;
102
- }
103
- });
108
+ },
109
+ );
104
110
 
105
111
  plugins
106
112
  .command("list")