@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 +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +10 -15
- package/src/__tests__/conversation-runtime-assembly.test.ts +24 -22
- package/src/__tests__/injector-chain.test.ts +25 -18
- package/src/__tests__/injector-disk-pressure.test.ts +19 -9
- package/src/__tests__/plugin-api-shim.test.ts +4 -4
- package/src/agent/loop.ts +97 -67
- package/src/cli/commands/plugins.ts +43 -37
- package/src/cli/lib/__tests__/install-from-github.test.ts +335 -263
- package/src/cli/lib/install-from-github.ts +332 -52
- package/src/daemon/conversation-agent-loop.ts +19 -21
- package/src/daemon/conversation-runtime-assembly.ts +27 -15
- package/src/daemon/conversation.ts +18 -19
- package/src/embedded/plugin-api.ts +2 -2
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +19 -0
- package/src/runtime/routes/plugins-routes.ts +6 -0
package/package.json
CHANGED
|
@@ -810,7 +810,7 @@ describe("session-agent-loop", () => {
|
|
|
810
810
|
});
|
|
811
811
|
});
|
|
812
812
|
|
|
813
|
-
test("freezes the
|
|
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
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
//
|
|
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
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
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
|
|
1814
|
-
//
|
|
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:
|
|
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
|
-
// (
|
|
1822
|
-
//
|
|
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
|
-
//
|
|
1919
|
-
//
|
|
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
|
|
2066
|
-
//
|
|
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:
|
|
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
|
-
// (
|
|
2074
|
-
//
|
|
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`
|
|
154
|
-
// conversation's frozen `currentTurnTemporalSnapshot
|
|
155
|
-
// chain without a caller-supplied `turnContext`
|
|
156
|
-
// runtime-assembly fallback conversation, so seed the snapshot
|
|
157
|
-
|
|
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: {
|
|
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:
|
|
401
|
+
timestamp: FIXED_TURN_TIMESTAMP,
|
|
391
402
|
interfaceName: "web",
|
|
392
403
|
});
|
|
393
|
-
seedFallbackTemporalSnapshot(
|
|
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
|
-
//
|
|
443
|
+
// computed live at injection time.
|
|
433
444
|
const unifiedTurn = buildUnifiedTurnContextBlock({
|
|
434
|
-
timestamp:
|
|
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:
|
|
581
|
+
timestamp: FIXED_TURN_TIMESTAMP,
|
|
575
582
|
interfaceName: "web",
|
|
576
583
|
});
|
|
577
|
-
seedWorkspaceContext("", {
|
|
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`
|
|
114
|
-
// conversation's frozen temporal snapshot
|
|
115
|
-
// unified-turn-context block is present.
|
|
116
|
-
function seedTemporalSnapshot(
|
|
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(
|
|
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(
|
|
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
|
|
37
|
+
Symbol.for("vellum.plugin-api"),
|
|
38
38
|
);
|
|
39
39
|
expect(source).toBe(
|
|
40
|
-
`const api = globalThis[Symbol.for("vellum.plugin-api
|
|
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
|
|
47
|
+
const source = buildShimSource([], Symbol.for("vellum.plugin-api"));
|
|
48
48
|
expect(source).toBe(
|
|
49
|
-
`const api = globalThis[Symbol.for("vellum.plugin-api
|
|
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
|
|
1615
|
-
//
|
|
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
|
-
//
|
|
1630
|
-
// the
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
process.exitCode = 1;
|
|
102
|
-
}
|
|
103
|
-
});
|
|
108
|
+
},
|
|
109
|
+
);
|
|
104
110
|
|
|
105
111
|
plugins
|
|
106
112
|
.command("list")
|