@bubblebrain-ai/bubble 0.0.39 → 0.0.41
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/dist/agent/internal-reminder-sanitizer.d.ts +8 -0
- package/dist/agent/internal-reminder-sanitizer.js +10 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +17 -0
- package/dist/context/compact.d.ts +15 -0
- package/dist/context/compact.js +50 -2
- package/dist/context/llm-compactor.js +17 -2
- package/dist/main.js +6 -1
- package/dist/print-output.d.ts +13 -0
- package/dist/print-output.js +2 -0
- package/dist/provider.js +19 -7
- package/package.json +1 -1
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { AssistantProviderMetadata } from "../types.js";
|
|
2
|
+
/** Prefix shared by every internal reminder/context block this module emits. */
|
|
3
|
+
export declare const INTERNAL_BLOCK_PREFIX = "<bubble_internal_";
|
|
4
|
+
/**
|
|
5
|
+
* True when the text is an internal reminder/context block (e.g. a meta
|
|
6
|
+
* message that the projector re-rolled into user role) rather than something
|
|
7
|
+
* the user actually typed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isInternalBlockContent(text: string): boolean;
|
|
2
10
|
export declare function formatInternalReminderBlock(kind: string, content: string): string;
|
|
3
11
|
export declare function formatInternalContextBlock(kind: string, content: string): string;
|
|
4
12
|
export declare function sanitizeInternalReminderBlocks(text: string): string;
|
|
@@ -27,6 +27,16 @@ const LEGACY_REMINDER_END_PHRASES = [
|
|
|
27
27
|
"- Skip the \"investigate the codebase\" step that applies to larger changes.",
|
|
28
28
|
"Do not put the final answer only in hidden reasoning.",
|
|
29
29
|
];
|
|
30
|
+
/** Prefix shared by every internal reminder/context block this module emits. */
|
|
31
|
+
export const INTERNAL_BLOCK_PREFIX = "<bubble_internal_";
|
|
32
|
+
/**
|
|
33
|
+
* True when the text is an internal reminder/context block (e.g. a meta
|
|
34
|
+
* message that the projector re-rolled into user role) rather than something
|
|
35
|
+
* the user actually typed.
|
|
36
|
+
*/
|
|
37
|
+
export function isInternalBlockContent(text) {
|
|
38
|
+
return text.trimStart().startsWith(INTERNAL_BLOCK_PREFIX);
|
|
39
|
+
}
|
|
30
40
|
export function formatInternalReminderBlock(kind, content) {
|
|
31
41
|
return formatInternalBlock("reminder", kind, content);
|
|
32
42
|
}
|
package/dist/agent.d.ts
CHANGED
|
@@ -175,6 +175,7 @@ export declare class Agent {
|
|
|
175
175
|
private readonly subagentUpdateWakers;
|
|
176
176
|
private lastInputTokens;
|
|
177
177
|
private lastAnchorMessageCount;
|
|
178
|
+
private compactionStats;
|
|
178
179
|
constructor(options: AgentOptions);
|
|
179
180
|
/**
|
|
180
181
|
* Re-inject the deferred-tools advertisement. Hosts that reassign
|
|
@@ -256,6 +257,13 @@ export declare class Agent {
|
|
|
256
257
|
private recoverFromOverflow;
|
|
257
258
|
compactResidentHistory(): void;
|
|
258
259
|
private maybeCompactWithLLM;
|
|
260
|
+
/** Snapshot of how often each compaction path rewrote history this run. */
|
|
261
|
+
getCompactionStats(): {
|
|
262
|
+
resident: number;
|
|
263
|
+
llm: number;
|
|
264
|
+
overflow: number;
|
|
265
|
+
droppedMessages: number;
|
|
266
|
+
};
|
|
259
267
|
/**
|
|
260
268
|
* Stream a 9-section handoff summary of `oldMessages` from the session model.
|
|
261
269
|
* Powers the manual `/compact` command: streaming (rather than `complete()`)
|
package/dist/agent.js
CHANGED
|
@@ -172,6 +172,12 @@ export class Agent {
|
|
|
172
172
|
subagentUpdateWakers = new Set();
|
|
173
173
|
lastInputTokens = null;
|
|
174
174
|
lastAnchorMessageCount = null;
|
|
175
|
+
// How often each compaction path actually rewrote history this run.
|
|
176
|
+
// Surfaced via getCompactionStats() for print-mode telemetry: without it,
|
|
177
|
+
// "did the model ever stop seeing the original instruction?" is
|
|
178
|
+
// unanswerable from the outside (only counts real compactions, not
|
|
179
|
+
// byte-saving projection rewrites).
|
|
180
|
+
compactionStats = { resident: 0, llm: 0, overflow: 0, droppedMessages: 0 };
|
|
175
181
|
constructor(options) {
|
|
176
182
|
this.provider = options.provider;
|
|
177
183
|
this.sessionID = options.sessionID;
|
|
@@ -992,6 +998,9 @@ export class Agent {
|
|
|
992
998
|
}
|
|
993
999
|
const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
|
|
994
1000
|
consecutiveOverflowRecoveries += 1;
|
|
1001
|
+
this.compactionStats.overflow += 1;
|
|
1002
|
+
this.compactionStats.droppedMessages += droppedMessages;
|
|
1003
|
+
traceEvent("compaction_fired", { path: "overflow", droppedMessages });
|
|
995
1004
|
yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
|
|
996
1005
|
continue;
|
|
997
1006
|
}
|
|
@@ -1507,10 +1516,16 @@ export class Agent {
|
|
|
1507
1516
|
this.lastInputTokens = null;
|
|
1508
1517
|
this.lastAnchorMessageCount = null;
|
|
1509
1518
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1519
|
+
this.compactionStats.llm += 1;
|
|
1520
|
+
traceEvent("compaction_fired", { path: "llm" });
|
|
1510
1521
|
}
|
|
1511
1522
|
// If LLM compaction failed for any reason, leave this.messages alone —
|
|
1512
1523
|
// the projector's algorithmic budgeted-mode passes will still try.
|
|
1513
1524
|
}
|
|
1525
|
+
/** Snapshot of how often each compaction path rewrote history this run. */
|
|
1526
|
+
getCompactionStats() {
|
|
1527
|
+
return { ...this.compactionStats };
|
|
1528
|
+
}
|
|
1514
1529
|
/**
|
|
1515
1530
|
* Stream a 9-section handoff summary of `oldMessages` from the session model.
|
|
1516
1531
|
* Powers the manual `/compact` command: streaming (rather than `complete()`)
|
|
@@ -2537,6 +2552,8 @@ export class Agent {
|
|
|
2537
2552
|
const compacted = compactMessages(candidate, { keepRecentTurns });
|
|
2538
2553
|
if (compacted.compacted && compacted.messages) {
|
|
2539
2554
|
candidate = compacted.messages;
|
|
2555
|
+
this.compactionStats.resident += 1;
|
|
2556
|
+
traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
|
|
2540
2557
|
}
|
|
2541
2558
|
}
|
|
2542
2559
|
const afterChars = estimateResidentChars(candidate);
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { Message } from "../types.js";
|
|
2
2
|
import type { SessionLogEntry } from "../session-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Compaction must never summarize away what the user actually asked for:
|
|
5
|
+
* requirements that appear late in a long instruction ("commit when done",
|
|
6
|
+
* "write the report in French") would otherwise vanish once the heuristic
|
|
7
|
+
* summary truncates the goal line. Messages larger than this cap are exempt
|
|
8
|
+
* from pinning (e.g. a pasted megabyte of logs as the opening message) —
|
|
9
|
+
* summarization is the lesser evil there.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PINNED_INSTRUCTION_MAX_CHARS = 8192;
|
|
12
|
+
/**
|
|
13
|
+
* Index of the first message the user actually wrote: user-role, not an
|
|
14
|
+
* internal reminder/context block re-rolled into user role by the projector,
|
|
15
|
+
* and small enough to keep verbatim.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findFirstRealUserIndex(messages: Message[]): number;
|
|
3
18
|
export interface CompactOptions {
|
|
4
19
|
keepRecentTurns?: number;
|
|
5
20
|
maxSummaryItems?: number;
|
package/dist/context/compact.js
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
import { isInternalBlockContent } from "../agent/internal-reminder-sanitizer.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compaction must never summarize away what the user actually asked for:
|
|
4
|
+
* requirements that appear late in a long instruction ("commit when done",
|
|
5
|
+
* "write the report in French") would otherwise vanish once the heuristic
|
|
6
|
+
* summary truncates the goal line. Messages larger than this cap are exempt
|
|
7
|
+
* from pinning (e.g. a pasted megabyte of logs as the opening message) —
|
|
8
|
+
* summarization is the lesser evil there.
|
|
9
|
+
*/
|
|
10
|
+
export const PINNED_INSTRUCTION_MAX_CHARS = 8192;
|
|
11
|
+
function userMessageText(message) {
|
|
12
|
+
if (typeof message.content === "string")
|
|
13
|
+
return message.content;
|
|
14
|
+
if (Array.isArray(message.content)) {
|
|
15
|
+
return message.content
|
|
16
|
+
.filter((part) => part.type === "text")
|
|
17
|
+
.map((part) => part.text)
|
|
18
|
+
.join(" ");
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Index of the first message the user actually wrote: user-role, not an
|
|
24
|
+
* internal reminder/context block re-rolled into user role by the projector,
|
|
25
|
+
* and small enough to keep verbatim.
|
|
26
|
+
*/
|
|
27
|
+
export function findFirstRealUserIndex(messages) {
|
|
28
|
+
for (let index = 0; index < messages.length; index++) {
|
|
29
|
+
const message = messages[index];
|
|
30
|
+
if (message.role !== "user")
|
|
31
|
+
continue;
|
|
32
|
+
const text = userMessageText(message);
|
|
33
|
+
if (!text.trim() || isInternalBlockContent(text))
|
|
34
|
+
continue;
|
|
35
|
+
if (text.length > PINNED_INSTRUCTION_MAX_CHARS)
|
|
36
|
+
return -1;
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
39
|
+
return -1;
|
|
40
|
+
}
|
|
1
41
|
export function planSessionCompaction(entries, options = {}) {
|
|
2
42
|
const keepRecentTurns = options.keepRecentTurns ?? 2;
|
|
3
43
|
const metadataEntries = entries.filter((entry) => entry.type === "metadata");
|
|
@@ -74,12 +114,20 @@ export function compactMessages(messages, options = {}) {
|
|
|
74
114
|
}
|
|
75
115
|
const oldMessages = conversationalMessages.slice(0, keepStartIndex);
|
|
76
116
|
const keptMessages = conversationalMessages.slice(keepStartIndex);
|
|
77
|
-
|
|
117
|
+
// Pin the original instruction: it stays verbatim above the summary instead
|
|
118
|
+
// of being crushed into the summary's 140-char goal line.
|
|
119
|
+
const pinnedIndex = findFirstRealUserIndex(oldMessages);
|
|
120
|
+
const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
|
|
121
|
+
const summaryInput = pinnedIndex >= 0
|
|
122
|
+
? oldMessages.filter((_, index) => index !== pinnedIndex)
|
|
123
|
+
: oldMessages;
|
|
124
|
+
const summary = buildMessageSummary(summaryInput, maxSummaryItems);
|
|
78
125
|
if (!summary) {
|
|
79
126
|
return { compacted: false };
|
|
80
127
|
}
|
|
81
128
|
const compactedMessages = [
|
|
82
129
|
...preservedContextMessages.map((message) => cloneMessage(message)),
|
|
130
|
+
...(pinnedMessage ? [cloneMessage(pinnedMessage)] : []),
|
|
83
131
|
{
|
|
84
132
|
role: "system",
|
|
85
133
|
content: `Previous conversation summary:\n${summary}`,
|
|
@@ -90,7 +138,7 @@ export function compactMessages(messages, options = {}) {
|
|
|
90
138
|
compacted: true,
|
|
91
139
|
summary,
|
|
92
140
|
messages: compactedMessages,
|
|
93
|
-
droppedEntries:
|
|
141
|
+
droppedEntries: summaryInput.length,
|
|
94
142
|
};
|
|
95
143
|
}
|
|
96
144
|
export function compactCurrentTurnToolGroups(messages, options = {}) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Failure modes are explicit: returns { compacted: false, reason } so the
|
|
11
11
|
// caller can fall back to algorithmic compaction without an exception.
|
|
12
12
|
import { estimateContextTokens } from "./budget.js";
|
|
13
|
+
import { findFirstRealUserIndex } from "./compact.js";
|
|
13
14
|
export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
|
14
15
|
|
|
15
16
|
Include:
|
|
@@ -40,9 +41,21 @@ export async function compactWithLLM(messages, options) {
|
|
|
40
41
|
// priorTurns: everything from earlier user turns (multi-turn case)
|
|
41
42
|
// lastUser: the user's current ask (always kept verbatim)
|
|
42
43
|
// currentTurn: the assistant + tool groups produced in response so far
|
|
44
|
+
//
|
|
45
|
+
// The FIRST real user message (the original instruction) is pinned verbatim
|
|
46
|
+
// too: after meta reminders get re-rolled into user role by the projector,
|
|
47
|
+
// "last user message" often points at a reminder, not the instruction — and
|
|
48
|
+
// late requirements in a long instruction must survive compaction.
|
|
43
49
|
const priorTurns = body.slice(0, lastUserIndex);
|
|
44
50
|
const lastUser = body[lastUserIndex];
|
|
45
51
|
const currentTurn = body.slice(lastUserIndex + 1);
|
|
52
|
+
const firstRealUserIndex = findFirstRealUserIndex(body);
|
|
53
|
+
const pinnedFirstUser = firstRealUserIndex >= 0 && firstRealUserIndex < lastUserIndex
|
|
54
|
+
? body[firstRealUserIndex]
|
|
55
|
+
: undefined;
|
|
56
|
+
const summarizablePriorTurns = pinnedFirstUser
|
|
57
|
+
? priorTurns.filter((message) => message !== pinnedFirstUser)
|
|
58
|
+
: priorTurns;
|
|
46
59
|
const groups = [];
|
|
47
60
|
let active = null;
|
|
48
61
|
for (const msg of currentTurn) {
|
|
@@ -61,9 +74,10 @@ export async function compactWithLLM(messages, options) {
|
|
|
61
74
|
const evictedGroups = groups.slice(0, groups.length - keptGroupCount);
|
|
62
75
|
const keptGroups = groups.slice(groups.length - keptGroupCount);
|
|
63
76
|
// What we'll send to the model to summarize: prior turns + the older groups
|
|
64
|
-
// in the current turn (everything we're about to evict).
|
|
77
|
+
// in the current turn (everything we're about to evict). The pinned first
|
|
78
|
+
// instruction is excluded — it survives verbatim, not as summary fodder.
|
|
65
79
|
const toSummarize = [
|
|
66
|
-
...
|
|
80
|
+
...summarizablePriorTurns,
|
|
67
81
|
...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
|
|
68
82
|
];
|
|
69
83
|
if (toSummarize.length === 0) {
|
|
@@ -105,6 +119,7 @@ export async function compactWithLLM(messages, options) {
|
|
|
105
119
|
}
|
|
106
120
|
const compacted = [
|
|
107
121
|
...preserved.map(cloneMessage),
|
|
122
|
+
...(pinnedFirstUser ? [cloneMessage(pinnedFirstUser)] : []),
|
|
108
123
|
{
|
|
109
124
|
role: "user",
|
|
110
125
|
content: `${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`,
|
package/dist/main.js
CHANGED
|
@@ -616,6 +616,7 @@ async function main() {
|
|
|
616
616
|
process.stdout.write(formatPrintJson({
|
|
617
617
|
summary: collector.summary(),
|
|
618
618
|
sessionId: printSessionId,
|
|
619
|
+
compaction: agent.getCompactionStats(),
|
|
619
620
|
}) + "\n");
|
|
620
621
|
}
|
|
621
622
|
else {
|
|
@@ -631,6 +632,7 @@ async function main() {
|
|
|
631
632
|
message: error instanceof Error ? error.message : String(error),
|
|
632
633
|
summary: collector.summary(),
|
|
633
634
|
sessionId: printSessionId,
|
|
635
|
+
compaction: agent.getCompactionStats(),
|
|
634
636
|
}) + "\n");
|
|
635
637
|
process.exitCode = 1;
|
|
636
638
|
}
|
|
@@ -834,7 +836,10 @@ async function readPipedStdin() {
|
|
|
834
836
|
}
|
|
835
837
|
main()
|
|
836
838
|
.then(() => {
|
|
837
|
-
|
|
839
|
+
// Preserve an exit code set by a handled failure path (e.g. print-mode
|
|
840
|
+
// agent errors set exitCode = 1 and return normally) — exit(0) here
|
|
841
|
+
// would silently override it.
|
|
842
|
+
void exitAfterFlush(typeof process.exitCode === "number" ? process.exitCode : 0);
|
|
838
843
|
})
|
|
839
844
|
.catch((err) => {
|
|
840
845
|
console.error(chalk.red(`Fatal error: ${err.message}`));
|
package/dist/print-output.d.ts
CHANGED
|
@@ -42,13 +42,26 @@ export declare class PrintRunCollector {
|
|
|
42
42
|
private addUsage;
|
|
43
43
|
summary(): PrintRunSummary;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Per-path counts of context compactions during the run. Lets benchmark
|
|
47
|
+
* harnesses correlate outcomes with "did the model ever lose sight of the
|
|
48
|
+
* full history" without access to the in-container session files.
|
|
49
|
+
*/
|
|
50
|
+
export interface PrintCompactionStats {
|
|
51
|
+
resident: number;
|
|
52
|
+
llm: number;
|
|
53
|
+
overflow: number;
|
|
54
|
+
droppedMessages: number;
|
|
55
|
+
}
|
|
45
56
|
export declare function formatPrintJson(input: {
|
|
46
57
|
summary: PrintRunSummary;
|
|
47
58
|
sessionId?: string;
|
|
48
59
|
stopReason?: "end_turn" | "error" | "cancelled";
|
|
60
|
+
compaction?: PrintCompactionStats;
|
|
49
61
|
}): string;
|
|
50
62
|
export declare function formatPrintJsonError(input: {
|
|
51
63
|
message: string;
|
|
52
64
|
summary?: PrintRunSummary;
|
|
53
65
|
sessionId?: string;
|
|
66
|
+
compaction?: PrintCompactionStats;
|
|
54
67
|
}): string;
|
package/dist/print-output.js
CHANGED
|
@@ -83,6 +83,7 @@ export function formatPrintJson(input) {
|
|
|
83
83
|
num_tool_calls: input.summary.num_tool_calls,
|
|
84
84
|
usage: input.summary.usage,
|
|
85
85
|
usage_reported: input.summary.usage_reported,
|
|
86
|
+
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
export function formatPrintJsonError(input) {
|
|
@@ -99,5 +100,6 @@ export function formatPrintJsonError(input) {
|
|
|
99
100
|
usage: input.summary.usage,
|
|
100
101
|
}
|
|
101
102
|
: {}),
|
|
103
|
+
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
102
104
|
});
|
|
103
105
|
}
|
package/dist/provider.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
|
|
|
15
15
|
import { resolveProviderRequestConfig } from "./provider-transform.js";
|
|
16
16
|
import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
|
|
17
17
|
import { RateLimitError } from "./network/errors.js";
|
|
18
|
+
import { ProviderStreamInterruptedError } from "./network/retry.js";
|
|
18
19
|
// Diagnostic logger for tool-args byte-loss investigation. Activate with
|
|
19
20
|
// BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
|
|
20
21
|
// Each line is a JSON record describing a transition. When debugging is off,
|
|
@@ -188,13 +189,24 @@ export function createProviderInstance(options) {
|
|
|
188
189
|
// surfaces a final 429 as a typed RateLimitError instead of a string.
|
|
189
190
|
let stream;
|
|
190
191
|
stream = (await createCompletion(body));
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
// A socket drop while iterating the SSE stream must surface as
|
|
193
|
+
// ProviderStreamInterruptedError so the agent loop re-issues the request;
|
|
194
|
+
// a raw network error here aborts the whole run (anthropic/codex/ai-sdk
|
|
195
|
+
// paths already wrap — this generic chat-completions path did not).
|
|
196
|
+
try {
|
|
197
|
+
yield* translateOpenAIStream(stream, {
|
|
198
|
+
toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
|
|
199
|
+
reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
|
|
200
|
+
textMergeMode: resolveTextMergeMode(options.providerId || "", options.baseURL),
|
|
201
|
+
debugProviderId: options.providerId || "",
|
|
202
|
+
debugModelId: chatOptions.model,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (chatOptions.abortSignal?.aborted)
|
|
207
|
+
throw error;
|
|
208
|
+
throw new ProviderStreamInterruptedError(`Provider stream interrupted: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
209
|
+
}
|
|
198
210
|
yield { type: "done" };
|
|
199
211
|
}
|
|
200
212
|
async function complete(messages, chatOptions) {
|