@bubblebrain-ai/bubble 0.0.7 → 0.0.9
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/categories.d.ts +34 -0
- package/dist/agent/categories.js +98 -0
- package/dist/agent/profiles.d.ts +4 -0
- package/dist/agent/profiles.js +2 -3
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-control.js +4 -0
- package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
- package/dist/agent/subagent-lifecycle-reminder.js +102 -0
- package/dist/agent/subagent-route-format.d.ts +8 -0
- package/dist/agent/subagent-route-format.js +18 -0
- package/dist/agent/subtask-policy.d.ts +0 -1
- package/dist/agent/subtask-policy.js +0 -4
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +188 -16
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -2
- package/dist/context/budget.js +29 -15
- package/dist/context/compact.d.ts +23 -0
- package/dist/context/compact.js +129 -0
- package/dist/context/llm-compactor.d.ts +19 -0
- package/dist/context/llm-compactor.js +200 -0
- package/dist/context/projector.js +28 -12
- package/dist/context/token-estimator.d.ts +14 -0
- package/dist/context/token-estimator.js +106 -0
- package/dist/context/tool-output-truncate.d.ts +8 -0
- package/dist/context/tool-output-truncate.js +59 -0
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +68 -7
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +17 -1
- package/dist/orchestrator/default-hooks.js +24 -18
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +22 -6
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +60 -2
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +7 -2
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/glob.js +2 -1
- package/dist/tools/grep.js +2 -2
- package/dist/tools/lsp.js +2 -2
- package/dist/tools/path-utils.d.ts +2 -0
- package/dist/tools/path-utils.js +16 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +207 -14
- package/dist/tools/write.js +3 -2
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +52 -0
- package/dist/tui-ink/app.js +1129 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +132 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +44 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +88 -0
- package/dist/tui-ink/code-highlight.d.ts +8 -0
- package/dist/tui-ink/code-highlight.js +122 -0
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +45 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +694 -0
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +394 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +667 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +331 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +105 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +99 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +37 -0
- package/dist/tui-ink/run.js +53 -0
- package/dist/tui-ink/theme.d.ts +66 -0
- package/dist/tui-ink/theme.js +115 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +46 -0
- package/dist/tui-ink/trace-groups.d.ts +27 -0
- package/dist/tui-ink/trace-groups.js +389 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +138 -0
- package/dist/types.d.ts +10 -0
- package/package.json +7 -1
package/dist/agent.js
CHANGED
|
@@ -6,18 +6,22 @@ import { compactMessages } from "./context/compact.js";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { compactMessagesWithLLM } from "./context/compact-llm.js";
|
|
8
8
|
import { getContextBudget } from "./context/budget.js";
|
|
9
|
+
import { buildContextUsageSnapshot } from "./context/usage.js";
|
|
9
10
|
import { isContextOverflowError } from "./context/overflow.js";
|
|
10
11
|
import { projectMessages } from "./context/projector.js";
|
|
11
12
|
import { aggressivePruneMessages } from "./context/prune.js";
|
|
13
|
+
import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
|
|
12
14
|
import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
|
|
13
15
|
import { HookBus } from "./orchestrator/hooks.js";
|
|
14
16
|
import { createDefaultHooks } from "./orchestrator/default-hooks.js";
|
|
17
|
+
import { resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
|
|
15
18
|
import { getSubtaskPolicy } from "./agent/subtask-policy.js";
|
|
16
19
|
import { composeAbortSignals } from "./agent/budget-ledger.js";
|
|
17
20
|
import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
|
|
18
21
|
import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
|
|
19
22
|
import { buildSystemPrompt } from "./system-prompt.js";
|
|
20
23
|
import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
|
|
24
|
+
import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
|
|
21
25
|
const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
|
|
22
26
|
const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
|
|
23
27
|
const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
|
|
@@ -56,6 +60,9 @@ export class Agent {
|
|
|
56
60
|
budgetSource;
|
|
57
61
|
skillSummaries;
|
|
58
62
|
memoryPrompt;
|
|
63
|
+
fileStateTracker;
|
|
64
|
+
agentCategories;
|
|
65
|
+
providerFactory;
|
|
59
66
|
subagentThreads = new Map();
|
|
60
67
|
pendingSubagentUpdates = [];
|
|
61
68
|
lastInputTokens = null;
|
|
@@ -80,6 +87,9 @@ export class Agent {
|
|
|
80
87
|
this.budgetSource = options.budgetSource ?? { runId: this.sessionID ?? "agent" };
|
|
81
88
|
this.skillSummaries = options.skills ?? [];
|
|
82
89
|
this.memoryPrompt = options.memoryPrompt;
|
|
90
|
+
this.fileStateTracker = options.fileStateTracker;
|
|
91
|
+
this.agentCategories = options.agentCategories ?? {};
|
|
92
|
+
this.providerFactory = options.providerFactory;
|
|
83
93
|
if (options.systemPrompt) {
|
|
84
94
|
this.messages.push({ role: "system", content: options.systemPrompt });
|
|
85
95
|
}
|
|
@@ -111,11 +121,26 @@ export class Agent {
|
|
|
111
121
|
listDeferredTools() {
|
|
112
122
|
return [...this.tools.values()].filter((t) => t.deferred);
|
|
113
123
|
}
|
|
124
|
+
getContextUsageSnapshot() {
|
|
125
|
+
return buildContextUsageSnapshot({
|
|
126
|
+
providerId: this.providerId,
|
|
127
|
+
modelId: this.apiModel,
|
|
128
|
+
messages: this.messages,
|
|
129
|
+
toolEntries: this.getActiveToolEntries(),
|
|
130
|
+
deferredToolEntries: this.listDeferredTools(),
|
|
131
|
+
skills: this.skillSummaries,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
114
134
|
/** Whether a given tool is deferred and not yet unlocked. */
|
|
115
135
|
isDeferredAndLocked(name) {
|
|
116
136
|
const tool = this.tools.get(name);
|
|
117
137
|
return !!tool?.deferred && !this.unlockedDeferred.has(name);
|
|
118
138
|
}
|
|
139
|
+
getActiveToolEntries() {
|
|
140
|
+
return [...this.tools.values()]
|
|
141
|
+
.filter((tool) => !tool.deferred || this.unlockedDeferred.has(tool.name))
|
|
142
|
+
.filter((tool) => this._mode === "plan" || tool.name !== "exit_plan_mode");
|
|
143
|
+
}
|
|
119
144
|
injectSystemReminder(content) {
|
|
120
145
|
this.appendMessage({ role: "meta", kind: "system-reminder", content });
|
|
121
146
|
}
|
|
@@ -297,6 +322,12 @@ export class Agent {
|
|
|
297
322
|
description: t.description,
|
|
298
323
|
parameters: t.parameters,
|
|
299
324
|
}));
|
|
325
|
+
// LLM-driven compaction runs ahead of projector's algorithmic passes. If
|
|
326
|
+
// it succeeds, this.messages is replaced with [preserved system+meta] +
|
|
327
|
+
// [LLM summary] + [last user msg], and the projector becomes a no-op for
|
|
328
|
+
// budget. If it fails (network error, etc.), the projector's existing
|
|
329
|
+
// algorithmic fallback still kicks in.
|
|
330
|
+
await this.maybeCompactWithLLM();
|
|
300
331
|
try {
|
|
301
332
|
const projectedMessages = projectMessages(this.messages, {
|
|
302
333
|
mode: "budgeted",
|
|
@@ -320,6 +351,15 @@ export class Agent {
|
|
|
320
351
|
yield { type: "text_delta", content: chunk.content };
|
|
321
352
|
break;
|
|
322
353
|
case "reasoning_delta":
|
|
354
|
+
debugReasoningStream({
|
|
355
|
+
stage: "agent_receive",
|
|
356
|
+
providerId: this._providerId,
|
|
357
|
+
modelId: this.apiModel,
|
|
358
|
+
turnStep: step,
|
|
359
|
+
beforeLength: assistantMsg.reasoning?.length ?? 0,
|
|
360
|
+
delta: summarizeDebugText(chunk.content),
|
|
361
|
+
afterLength: (assistantMsg.reasoning?.length ?? 0) + chunk.content.length,
|
|
362
|
+
});
|
|
323
363
|
assistantMsg.reasoning = (assistantMsg.reasoning || "") + chunk.content;
|
|
324
364
|
yield { type: "reasoning_delta", content: chunk.content };
|
|
325
365
|
break;
|
|
@@ -498,10 +538,14 @@ export class Agent {
|
|
|
498
538
|
result = next;
|
|
499
539
|
},
|
|
500
540
|
});
|
|
541
|
+
// Honor the model's server-declared per-tool-output token cap (e.g.
|
|
542
|
+
// gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
|
|
543
|
+
// blow past the input window even though our local estimate looks fine.
|
|
544
|
+
const truncatedOutput = truncateToolOutputForModel(result.content, this.providerId, this.apiModel);
|
|
501
545
|
this.appendMessage({
|
|
502
546
|
role: "tool",
|
|
503
547
|
toolCallId: tc.id,
|
|
504
|
-
content:
|
|
548
|
+
content: truncatedOutput.content,
|
|
505
549
|
metadata: result.metadata,
|
|
506
550
|
isError: result.isError,
|
|
507
551
|
});
|
|
@@ -533,7 +577,7 @@ export class Agent {
|
|
|
533
577
|
},
|
|
534
578
|
});
|
|
535
579
|
flushGovernorReminders();
|
|
536
|
-
yield { type: "turn_end", usage: turnUsage };
|
|
580
|
+
yield { type: "turn_end", usage: turnUsage, willContinue: true };
|
|
537
581
|
// Auto-continue: if we have tool results, the LLM needs to respond to them.
|
|
538
582
|
// Emitting the turn boundary keeps UI renderers aligned with the persisted
|
|
539
583
|
// assistant/tool message sequence instead of merging the next answer into
|
|
@@ -549,8 +593,9 @@ export class Agent {
|
|
|
549
593
|
flushReminders: flushGovernorReminders,
|
|
550
594
|
});
|
|
551
595
|
flushGovernorReminders();
|
|
552
|
-
|
|
553
|
-
|
|
596
|
+
const willContinue = !!hookState.forceContinuationReason;
|
|
597
|
+
yield { type: "turn_end", usage: turnUsage, willContinue };
|
|
598
|
+
if (willContinue) {
|
|
554
599
|
delete hookState.forceContinuationReason;
|
|
555
600
|
continue;
|
|
556
601
|
}
|
|
@@ -569,6 +614,7 @@ export class Agent {
|
|
|
569
614
|
if (afterTokens < beforeTokens) {
|
|
570
615
|
this.lastInputTokens = null;
|
|
571
616
|
this.lastAnchorMessageCount = null;
|
|
617
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
572
618
|
return before - this.messages.length;
|
|
573
619
|
}
|
|
574
620
|
}
|
|
@@ -583,6 +629,22 @@ export class Agent {
|
|
|
583
629
|
this.messages = llmResult.messages;
|
|
584
630
|
this.lastInputTokens = null;
|
|
585
631
|
this.lastAnchorMessageCount = null;
|
|
632
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
633
|
+
return before - this.messages.length;
|
|
634
|
+
}
|
|
635
|
+
// Single-turn capable LLM compactor. compactMessagesWithLLM above no-ops
|
|
636
|
+
// when there's only one user turn (the "single huge prompt with many tool
|
|
637
|
+
// calls" case), so try the turn-internal compactor before giving up.
|
|
638
|
+
const { compactWithLLM } = await import("./context/llm-compactor.js");
|
|
639
|
+
const singleTurnResult = await compactWithLLM(this.messages, {
|
|
640
|
+
provider: this.provider,
|
|
641
|
+
modelId: this.apiModel,
|
|
642
|
+
});
|
|
643
|
+
if (singleTurnResult.compacted && singleTurnResult.messages) {
|
|
644
|
+
this.messages = singleTurnResult.messages;
|
|
645
|
+
this.lastInputTokens = null;
|
|
646
|
+
this.lastAnchorMessageCount = null;
|
|
647
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
586
648
|
return before - this.messages.length;
|
|
587
649
|
}
|
|
588
650
|
const fallback = compactMessages(this.messages, { keepRecentTurns });
|
|
@@ -590,6 +652,21 @@ export class Agent {
|
|
|
590
652
|
this.messages = fallback.messages;
|
|
591
653
|
this.lastInputTokens = null;
|
|
592
654
|
this.lastAnchorMessageCount = null;
|
|
655
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
656
|
+
return before - this.messages.length;
|
|
657
|
+
}
|
|
658
|
+
// Codex-style last-resort: drop the single oldest non-protected message
|
|
659
|
+
// and let the retry loop try again. Cheap, but eventually narrows even an
|
|
660
|
+
// intractable single-turn overflow.
|
|
661
|
+
const oldestIdx = this.messages.findIndex((m) => m.role !== "system" && m.role !== "meta");
|
|
662
|
+
if (oldestIdx >= 0 && oldestIdx < this.messages.length - 1) {
|
|
663
|
+
this.messages = [
|
|
664
|
+
...this.messages.slice(0, oldestIdx),
|
|
665
|
+
...this.messages.slice(oldestIdx + 1),
|
|
666
|
+
];
|
|
667
|
+
this.lastInputTokens = null;
|
|
668
|
+
this.lastAnchorMessageCount = null;
|
|
669
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
593
670
|
return before - this.messages.length;
|
|
594
671
|
}
|
|
595
672
|
return 0;
|
|
@@ -597,6 +674,34 @@ export class Agent {
|
|
|
597
674
|
compactResidentHistory() {
|
|
598
675
|
this.maybeCompactResidentHistory();
|
|
599
676
|
}
|
|
677
|
+
async maybeCompactWithLLM() {
|
|
678
|
+
if (!this.providerId || !this.apiModel)
|
|
679
|
+
return;
|
|
680
|
+
if (this.messages.length === 0)
|
|
681
|
+
return;
|
|
682
|
+
const tail = this.lastAnchorMessageCount !== null
|
|
683
|
+
? this.messages.slice(this.lastAnchorMessageCount)
|
|
684
|
+
: undefined;
|
|
685
|
+
const budget = getContextBudget(this.providerId, this.apiModel, this.messages, {
|
|
686
|
+
usageAnchorTokens: this.lastInputTokens ?? undefined,
|
|
687
|
+
tailMessages: tail,
|
|
688
|
+
});
|
|
689
|
+
if (!budget.shouldCompact)
|
|
690
|
+
return;
|
|
691
|
+
const { compactWithLLM } = await import("./context/llm-compactor.js");
|
|
692
|
+
const result = await compactWithLLM(this.messages, {
|
|
693
|
+
provider: this.provider,
|
|
694
|
+
modelId: this.apiModel,
|
|
695
|
+
});
|
|
696
|
+
if (result.compacted && result.messages) {
|
|
697
|
+
this.messages = result.messages;
|
|
698
|
+
this.lastInputTokens = null;
|
|
699
|
+
this.lastAnchorMessageCount = null;
|
|
700
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
701
|
+
}
|
|
702
|
+
// If LLM compaction failed for any reason, leave this.messages alone —
|
|
703
|
+
// the projector's algorithmic budgeted-mode passes will still try.
|
|
704
|
+
}
|
|
600
705
|
async runSubtask(input, cwd, options) {
|
|
601
706
|
const subtaskType = options?.subtaskType;
|
|
602
707
|
const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
|
|
@@ -606,6 +711,7 @@ export class Agent {
|
|
|
606
711
|
runId: randomUUID(),
|
|
607
712
|
subAgentId: randomUUID(),
|
|
608
713
|
parentToolCallId: "task",
|
|
714
|
+
route: this.resolveRouteForSubagent(profile, undefined),
|
|
609
715
|
description: options?.description,
|
|
610
716
|
});
|
|
611
717
|
const lines = [
|
|
@@ -641,6 +747,7 @@ export class Agent {
|
|
|
641
747
|
parentToolCallId: options.parentToolCallId,
|
|
642
748
|
parentToolName: "subagent",
|
|
643
749
|
nickname: options.nickname,
|
|
750
|
+
route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
|
|
644
751
|
});
|
|
645
752
|
await this.runSubagentThread(record, input, cwd, {
|
|
646
753
|
approval: options.approval ?? options.profile.approval,
|
|
@@ -656,6 +763,7 @@ export class Agent {
|
|
|
656
763
|
task: typeof input === "string" ? input : "(multimodal task)",
|
|
657
764
|
parentToolCallId: options.parentToolCallId,
|
|
658
765
|
parentToolName: "spawn_agent",
|
|
766
|
+
route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
|
|
659
767
|
});
|
|
660
768
|
this.subagentThreads.set(record.agentId, record);
|
|
661
769
|
this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
|
|
@@ -746,6 +854,31 @@ export class Agent {
|
|
|
746
854
|
listSubAgents() {
|
|
747
855
|
return [...this.subagentThreads.values()].map(snapshotSubagentThread);
|
|
748
856
|
}
|
|
857
|
+
resolveRouteForSubagent(profile, category) {
|
|
858
|
+
const parentRoute = {
|
|
859
|
+
providerId: this.providerId,
|
|
860
|
+
model: this.apiModel,
|
|
861
|
+
thinkingLevel: this.thinkingLevel,
|
|
862
|
+
};
|
|
863
|
+
const resolved = resolveSubagentRoute(category ?? profile.category, {
|
|
864
|
+
...parentRoute,
|
|
865
|
+
}, this.agentCategories);
|
|
866
|
+
if ("error" in resolved) {
|
|
867
|
+
throw new Error(resolved.error);
|
|
868
|
+
}
|
|
869
|
+
if (profile.model && profile.model !== "inherit") {
|
|
870
|
+
const model = resolveModelRoute(profile.model, parentRoute.providerId);
|
|
871
|
+
if (model.model !== "inherit") {
|
|
872
|
+
return {
|
|
873
|
+
...resolved.route,
|
|
874
|
+
providerId: model.providerId,
|
|
875
|
+
model: model.model,
|
|
876
|
+
inherited: false,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return resolved.route;
|
|
881
|
+
}
|
|
749
882
|
createSubagentThreadRecord(options) {
|
|
750
883
|
const now = Date.now();
|
|
751
884
|
const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
|
|
@@ -754,6 +887,8 @@ export class Agent {
|
|
|
754
887
|
runId: options.runId ?? randomUUID(),
|
|
755
888
|
nickname,
|
|
756
889
|
profile: options.profile,
|
|
890
|
+
category: options.route?.category,
|
|
891
|
+
route: options.route,
|
|
757
892
|
parentToolCallId: options.parentToolCallId,
|
|
758
893
|
parentToolName: options.parentToolName,
|
|
759
894
|
status: "queued",
|
|
@@ -789,9 +924,20 @@ export class Agent {
|
|
|
789
924
|
return;
|
|
790
925
|
}
|
|
791
926
|
const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
927
|
+
let subAgent;
|
|
928
|
+
try {
|
|
929
|
+
subAgent = options.reuseAgent && record.agent
|
|
930
|
+
? record.agent
|
|
931
|
+
: await this.createSubAgentInstance(record, tools, cwd, options.forkContext);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
record.status = "blocked";
|
|
935
|
+
record.error = error?.message || String(error);
|
|
936
|
+
record.updatedAt = Date.now();
|
|
937
|
+
emit("blocked", undefined, record.error);
|
|
938
|
+
this.notifySubagentWaiters(record);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
795
941
|
record.agent = subAgent;
|
|
796
942
|
record.status = "running";
|
|
797
943
|
record.updatedAt = Date.now();
|
|
@@ -892,14 +1038,21 @@ export class Agent {
|
|
|
892
1038
|
record.summary = finalSummary;
|
|
893
1039
|
}
|
|
894
1040
|
}
|
|
895
|
-
createSubAgentInstance(record, tools, cwd, forkContext) {
|
|
1041
|
+
async createSubAgentInstance(record, tools, cwd, forkContext) {
|
|
896
1042
|
const childToolNames = tools.map((tool) => tool.name);
|
|
1043
|
+
const route = record.route ?? {
|
|
1044
|
+
providerId: this.providerId,
|
|
1045
|
+
model: this.apiModel,
|
|
1046
|
+
thinkingLevel: this.thinkingLevel,
|
|
1047
|
+
inherited: true,
|
|
1048
|
+
};
|
|
1049
|
+
const provider = await this.resolveProviderForRoute(route);
|
|
897
1050
|
const childSystemPrompt = buildSystemPrompt({
|
|
898
1051
|
agentName: "Bubble",
|
|
899
|
-
configuredProvider:
|
|
900
|
-
configuredModel:
|
|
901
|
-
configuredModelId:
|
|
902
|
-
thinkingLevel:
|
|
1052
|
+
configuredProvider: route.providerId || "none",
|
|
1053
|
+
configuredModel: route.model || "none",
|
|
1054
|
+
configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
|
|
1055
|
+
thinkingLevel: route.thinkingLevel,
|
|
903
1056
|
mode: "plan",
|
|
904
1057
|
workingDir: cwd,
|
|
905
1058
|
tools: childToolNames,
|
|
@@ -913,24 +1066,38 @@ export class Agent {
|
|
|
913
1066
|
].filter(Boolean).join("\n\n"),
|
|
914
1067
|
});
|
|
915
1068
|
const subAgent = new Agent({
|
|
916
|
-
provider
|
|
917
|
-
providerId:
|
|
918
|
-
model:
|
|
1069
|
+
provider,
|
|
1070
|
+
providerId: route.providerId,
|
|
1071
|
+
model: route.model,
|
|
919
1072
|
tools,
|
|
920
1073
|
temperature: this.temperature,
|
|
921
|
-
thinkingLevel:
|
|
1074
|
+
thinkingLevel: route.thinkingLevel,
|
|
922
1075
|
mode: "plan",
|
|
923
1076
|
maxTurns: record.profile.maxTurns,
|
|
924
1077
|
budgetLedger: this.budgetLedger,
|
|
925
1078
|
budgetSource: { runId: record.runId, subAgentId: record.agentId },
|
|
926
1079
|
systemPrompt: childSystemPrompt,
|
|
927
1080
|
hooks: this.hookDefinitions,
|
|
1081
|
+
agentCategories: this.agentCategories,
|
|
1082
|
+
providerFactory: this.providerFactory,
|
|
928
1083
|
});
|
|
929
1084
|
if (forkContext) {
|
|
930
1085
|
subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
|
|
931
1086
|
}
|
|
932
1087
|
return subAgent;
|
|
933
1088
|
}
|
|
1089
|
+
async resolveProviderForRoute(route) {
|
|
1090
|
+
if (!route.providerId || route.providerId === this.providerId) {
|
|
1091
|
+
return this.provider;
|
|
1092
|
+
}
|
|
1093
|
+
if (!this.providerFactory) {
|
|
1094
|
+
throw new Error([
|
|
1095
|
+
`Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
|
|
1096
|
+
`but the parent agent only has provider "${this.providerId || "none"}" and no provider factory is configured.`,
|
|
1097
|
+
].join(" "));
|
|
1098
|
+
}
|
|
1099
|
+
return this.providerFactory(route);
|
|
1100
|
+
}
|
|
934
1101
|
forkMessagesForSubagent(childSystemPrompt) {
|
|
935
1102
|
const forked = this.messages
|
|
936
1103
|
.filter((message) => {
|
|
@@ -955,6 +1122,8 @@ export class Agent {
|
|
|
955
1122
|
subAgentId: record.agentId,
|
|
956
1123
|
agentName: record.profile.name,
|
|
957
1124
|
nickname: record.nickname,
|
|
1125
|
+
category: record.category,
|
|
1126
|
+
route: record.route,
|
|
958
1127
|
status,
|
|
959
1128
|
childEvent: event,
|
|
960
1129
|
summaryDelta: event?.type === "text_delta" ? event.content : undefined,
|
|
@@ -968,6 +1137,8 @@ export class Agent {
|
|
|
968
1137
|
subAgentId: record.agentId,
|
|
969
1138
|
agentName: record.profile.name,
|
|
970
1139
|
nickname: record.nickname,
|
|
1140
|
+
category: record.category,
|
|
1141
|
+
route: record.route,
|
|
971
1142
|
status,
|
|
972
1143
|
profileSource: record.profile.source,
|
|
973
1144
|
task: record.task,
|
|
@@ -1056,6 +1227,7 @@ export class Agent {
|
|
|
1056
1227
|
this.messages = candidate;
|
|
1057
1228
|
this.lastInputTokens = null;
|
|
1058
1229
|
this.lastAnchorMessageCount = null;
|
|
1230
|
+
this.fileStateTracker?.invalidateReadHistory();
|
|
1059
1231
|
}
|
|
1060
1232
|
}
|
|
1061
1233
|
appendMessage(message) {
|
package/dist/config.d.ts
CHANGED
|
@@ -3,17 +3,33 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uses a single JSON file in Bubble home, normally ~/.bubble/config.json.
|
|
5
5
|
*/
|
|
6
|
+
import { type AgentCategoriesConfig } from "./agent/categories.js";
|
|
6
7
|
import type { ProviderProfile } from "./provider-registry.js";
|
|
7
8
|
import type { ThinkingLevel } from "./types.js";
|
|
9
|
+
export type ThemeMode = "auto" | "light" | "dark";
|
|
10
|
+
export interface ThemeConfig {
|
|
11
|
+
mode: ThemeMode;
|
|
12
|
+
overrides?: Record<string, string>;
|
|
13
|
+
}
|
|
8
14
|
export interface UserConfigData {
|
|
9
15
|
defaultModel?: string;
|
|
10
16
|
defaultThinkingLevel?: ThinkingLevel;
|
|
11
17
|
skillPaths?: string[];
|
|
12
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Three shapes are accepted on disk so we can evolve without breaking
|
|
20
|
+
* existing configs:
|
|
21
|
+
* - `"auto" | "light" | "dark"` — mode only
|
|
22
|
+
* - `{ mode, overrides? }` — mode + optional per-key palette overrides
|
|
23
|
+
* - `Record<string, string>` (legacy) — treated as `{ mode: "dark", overrides }`
|
|
24
|
+
* so users who customized colors before light-mode existed keep their
|
|
25
|
+
* palette and stay on dark, which was the only palette at the time.
|
|
26
|
+
*/
|
|
27
|
+
theme?: ThemeMode | ThemeConfig | Record<string, string>;
|
|
13
28
|
recentModels?: string[];
|
|
14
29
|
apiKey?: string;
|
|
15
30
|
providers?: ProviderProfile[];
|
|
16
31
|
defaultProvider?: string;
|
|
32
|
+
agentCategories?: AgentCategoriesConfig;
|
|
17
33
|
}
|
|
18
34
|
export declare class UserConfig {
|
|
19
35
|
private data;
|
|
@@ -34,8 +50,12 @@ export declare class UserConfig {
|
|
|
34
50
|
setDefaultProvider(id: string): void;
|
|
35
51
|
getSkillPaths(): string[];
|
|
36
52
|
setSkillPaths(paths: string[]): void;
|
|
37
|
-
getTheme():
|
|
38
|
-
|
|
53
|
+
getTheme(): ThemeConfig;
|
|
54
|
+
getThemeMode(): ThemeMode;
|
|
55
|
+
getThemeOverrides(): Record<string, string>;
|
|
56
|
+
setThemeMode(mode: ThemeMode): void;
|
|
57
|
+
setThemeOverrides(overrides: Record<string, string>): void;
|
|
58
|
+
getAgentCategories(): AgentCategoriesConfig;
|
|
39
59
|
}
|
|
40
60
|
/** Mask an API key for safe display. */
|
|
41
61
|
export declare function maskKey(key: string): string;
|
package/dist/config.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { getBubbleHome } from "./bubble-home.js";
|
|
9
|
+
import { sanitizeAgentCategories } from "./agent/categories.js";
|
|
9
10
|
const HIDDEN_PROVIDER_IDS = new Set(["openrouter", "openai-codex"]);
|
|
10
11
|
function getConfigPath() {
|
|
11
12
|
return join(getBubbleHome(), "config.json");
|
|
@@ -36,6 +37,39 @@ function sanitizeDefaultModel(model) {
|
|
|
36
37
|
function sanitizeDefaultProvider(providerId) {
|
|
37
38
|
return isHiddenProviderId(providerId) ? undefined : providerId;
|
|
38
39
|
}
|
|
40
|
+
function sanitizeTheme(value) {
|
|
41
|
+
if (value == null)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
return value === "auto" || value === "light" || value === "dark"
|
|
45
|
+
? { mode: value }
|
|
46
|
+
: undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
// Discriminate the new `{ mode, overrides }` shape from the legacy
|
|
51
|
+
// `Record<string, string>` shape. A legacy config has no `mode` key.
|
|
52
|
+
const maybeNew = value;
|
|
53
|
+
if (typeof maybeNew.mode === "string") {
|
|
54
|
+
const mode = maybeNew.mode;
|
|
55
|
+
if (mode !== "auto" && mode !== "light" && mode !== "dark")
|
|
56
|
+
return undefined;
|
|
57
|
+
const overrides = isStringMap(maybeNew.overrides) ? maybeNew.overrides : undefined;
|
|
58
|
+
return overrides ? { mode, overrides } : { mode };
|
|
59
|
+
}
|
|
60
|
+
const overrides = pickStringEntries(value);
|
|
61
|
+
if (Object.keys(overrides).length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { mode: "dark", overrides };
|
|
64
|
+
}
|
|
65
|
+
function isStringMap(value) {
|
|
66
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
67
|
+
return false;
|
|
68
|
+
return Object.values(value).every((entry) => typeof entry === "string");
|
|
69
|
+
}
|
|
70
|
+
function pickStringEntries(value) {
|
|
71
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string"));
|
|
72
|
+
}
|
|
39
73
|
export class UserConfig {
|
|
40
74
|
data = {};
|
|
41
75
|
constructor() {
|
|
@@ -54,6 +88,8 @@ export class UserConfig {
|
|
|
54
88
|
recentModels: sanitizeRecentModels(parsed.recentModels),
|
|
55
89
|
providers: sanitizeProviders(parsed.providers),
|
|
56
90
|
defaultProvider: sanitizeDefaultProvider(parsed.defaultProvider),
|
|
91
|
+
agentCategories: sanitizeAgentCategories(parsed.agentCategories),
|
|
92
|
+
theme: sanitizeTheme(parsed.theme),
|
|
57
93
|
};
|
|
58
94
|
}
|
|
59
95
|
catch {
|
|
@@ -126,15 +162,32 @@ export class UserConfig {
|
|
|
126
162
|
this.save();
|
|
127
163
|
}
|
|
128
164
|
getTheme() {
|
|
129
|
-
const theme = this.data.theme;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
165
|
+
const theme = sanitizeTheme(this.data.theme);
|
|
166
|
+
return theme ?? { mode: "auto" };
|
|
167
|
+
}
|
|
168
|
+
getThemeMode() {
|
|
169
|
+
return this.getTheme().mode;
|
|
170
|
+
}
|
|
171
|
+
getThemeOverrides() {
|
|
172
|
+
return this.getTheme().overrides ?? {};
|
|
133
173
|
}
|
|
134
|
-
|
|
135
|
-
|
|
174
|
+
setThemeMode(mode) {
|
|
175
|
+
const current = this.getTheme();
|
|
176
|
+
this.data.theme = current.overrides
|
|
177
|
+
? { mode, overrides: current.overrides }
|
|
178
|
+
: { mode };
|
|
136
179
|
this.save();
|
|
137
180
|
}
|
|
181
|
+
setThemeOverrides(overrides) {
|
|
182
|
+
const current = this.getTheme();
|
|
183
|
+
this.data.theme = Object.keys(overrides).length === 0
|
|
184
|
+
? { mode: current.mode }
|
|
185
|
+
: { mode: current.mode, overrides: { ...overrides } };
|
|
186
|
+
this.save();
|
|
187
|
+
}
|
|
188
|
+
getAgentCategories() {
|
|
189
|
+
return sanitizeAgentCategories(this.data.agentCategories);
|
|
190
|
+
}
|
|
138
191
|
}
|
|
139
192
|
/** Mask an API key for safe display. */
|
|
140
193
|
export function maskKey(key) {
|
package/dist/context/budget.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface ContextBudgetOptions {
|
|
|
16
16
|
/** Messages appended after the anchor (their tokens are estimated and added). */
|
|
17
17
|
tailMessages?: Message[];
|
|
18
18
|
}
|
|
19
|
-
export declare function estimateMessageTokens(message: Message): number;
|
|
20
|
-
export declare function estimateContextTokens(messages: Message[]): number;
|
|
19
|
+
export declare function estimateMessageTokens(message: Message, providerId?: string): number;
|
|
20
|
+
export declare function estimateContextTokens(messages: Message[], providerId?: string): number;
|
|
21
21
|
export declare function getContextBudget(providerId: string, modelId: string, messages: Message[], options?: ContextBudgetOptions): ContextBudget;
|
|
22
|
+
export declare function estimateTextTokens(text: string, providerId?: string): number;
|
package/dist/context/budget.js
CHANGED
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
import { getModelContextWindow } from "../model-catalog.js";
|
|
2
|
+
import { getTokenEstimator } from "./token-estimator.js";
|
|
2
3
|
export const OUTPUT_RESERVE_TOKENS = 20_000;
|
|
3
4
|
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
|
|
4
5
|
export const PRUNE_BUFFER_TOKENS = 50_000;
|
|
5
6
|
export const MIN_WINDOW_FOR_RESERVE = 40_000;
|
|
6
|
-
|
|
7
|
+
// Safety margins applied to estimator-derived token counts. The estimator can
|
|
8
|
+
// undercount on dense / CJK / tool-payload content; treating its output as a
|
|
9
|
+
// hard floor means we'd routinely overshoot the real server-side count. These
|
|
10
|
+
// multipliers bias the budget decision toward earlier compaction.
|
|
11
|
+
const TAIL_SAFETY_MARGIN = 1.15; // applied to estimated tail when anchored
|
|
12
|
+
const FIRST_TURN_SAFETY_MARGIN = 1.25; // applied when there's no anchor yet
|
|
13
|
+
export function estimateMessageTokens(message, providerId) {
|
|
14
|
+
const estimate = (text) => estimateTextTokens(text, providerId);
|
|
7
15
|
switch (message.role) {
|
|
8
16
|
case "system":
|
|
9
17
|
case "meta":
|
|
10
18
|
case "tool":
|
|
11
|
-
return
|
|
19
|
+
return estimate(message.content);
|
|
12
20
|
case "assistant":
|
|
13
|
-
return
|
|
14
|
-
+
|
|
15
|
-
+ (message.toolCalls?.reduce((sum, toolCall) => sum +
|
|
21
|
+
return estimate(message.content)
|
|
22
|
+
+ estimate(message.reasoning ?? "")
|
|
23
|
+
+ (message.toolCalls?.reduce((sum, toolCall) => sum + estimate(toolCall.arguments) + 12, 0) ?? 0)
|
|
16
24
|
+ 8;
|
|
17
25
|
case "user":
|
|
18
26
|
if (typeof message.content === "string") {
|
|
19
|
-
return
|
|
27
|
+
return estimate(message.content) + 8;
|
|
20
28
|
}
|
|
21
29
|
return message.content.reduce((sum, part) => {
|
|
22
30
|
if (part.type === "text") {
|
|
23
|
-
return sum +
|
|
31
|
+
return sum + estimate(part.text);
|
|
24
32
|
}
|
|
25
33
|
return sum + 256;
|
|
26
34
|
}, 8);
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
|
-
export function estimateContextTokens(messages) {
|
|
30
|
-
return messages.reduce((sum, message) => sum + estimateMessageTokens(message), 0);
|
|
37
|
+
export function estimateContextTokens(messages, providerId) {
|
|
38
|
+
return messages.reduce((sum, message) => sum + estimateMessageTokens(message, providerId), 0);
|
|
31
39
|
}
|
|
32
40
|
export function getContextBudget(providerId, modelId, messages, options = {}) {
|
|
33
|
-
const estimatedTokens = computeEstimatedTokens(messages, options);
|
|
41
|
+
const estimatedTokens = computeEstimatedTokens(providerId, messages, options);
|
|
34
42
|
const contextWindow = getModelContextWindow(providerId, modelId);
|
|
35
43
|
const percent = contextWindow ? Math.min(100, (estimatedTokens / contextWindow) * 100) : undefined;
|
|
36
44
|
return {
|
|
@@ -41,11 +49,17 @@ export function getContextBudget(providerId, modelId, messages, options = {}) {
|
|
|
41
49
|
shouldCompact: shouldTriggerCompact(estimatedTokens, contextWindow),
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
|
-
function computeEstimatedTokens(messages, options) {
|
|
52
|
+
function computeEstimatedTokens(providerId, messages, options) {
|
|
45
53
|
if (options.usageAnchorTokens !== undefined && options.tailMessages) {
|
|
46
|
-
|
|
54
|
+
// Anchor is authoritative (server-reported input tokens from the last
|
|
55
|
+
// response). Tail goes through our estimator and may undercount on dense /
|
|
56
|
+
// tool-output content, so we inflate it by a small margin before adding.
|
|
57
|
+
const tailEstimate = estimateContextTokens(options.tailMessages, providerId);
|
|
58
|
+
return options.usageAnchorTokens + Math.ceil(tailEstimate * TAIL_SAFETY_MARGIN);
|
|
47
59
|
}
|
|
48
|
-
|
|
60
|
+
// First turn (or anchor lost): there's no server-reported baseline at all,
|
|
61
|
+
// so apply a larger safety margin to the pure estimate.
|
|
62
|
+
return Math.ceil(estimateContextTokens(messages, providerId) * FIRST_TURN_SAFETY_MARGIN);
|
|
49
63
|
}
|
|
50
64
|
function shouldTriggerPrune(estimatedTokens, contextWindow) {
|
|
51
65
|
if (!contextWindow) {
|
|
@@ -65,9 +79,9 @@ function shouldTriggerCompact(estimatedTokens, contextWindow) {
|
|
|
65
79
|
: contextWindow * 0.75;
|
|
66
80
|
return estimatedTokens >= threshold;
|
|
67
81
|
}
|
|
68
|
-
function estimateTextTokens(text) {
|
|
82
|
+
export function estimateTextTokens(text, providerId) {
|
|
69
83
|
if (!text) {
|
|
70
84
|
return 0;
|
|
71
85
|
}
|
|
72
|
-
return
|
|
86
|
+
return getTokenEstimator(providerId).estimate(text);
|
|
73
87
|
}
|
|
@@ -13,3 +13,26 @@ export interface CompactResult {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function compactSessionEntries(entries: SessionLogEntry[], options?: CompactOptions): CompactResult;
|
|
15
15
|
export declare function compactMessages(messages: Message[], options?: CompactOptions): CompactResult;
|
|
16
|
+
/**
|
|
17
|
+
* Sub-turn compaction.
|
|
18
|
+
*
|
|
19
|
+
* When the active user turn has accumulated many (assistant + tool-result) groups
|
|
20
|
+
* — typically a single "look at this project" prompt that triggers a dozen file
|
|
21
|
+
* reads — multi-turn compactMessages above is a no-op (there's only one user turn
|
|
22
|
+
* to summarize). This variant operates one level finer: it groups messages inside
|
|
23
|
+
* the last user turn by assistant message, keeps the most recent K groups intact,
|
|
24
|
+
* and replaces the older ones with a synthetic system message that names the tools
|
|
25
|
+
* called and files inspected.
|
|
26
|
+
*
|
|
27
|
+
* Constraints honored:
|
|
28
|
+
* - Older groups are dropped WHOLE (assistant + its tool results). Dropping just
|
|
29
|
+
* the tool results would leave orphan tool_calls; repairToolCallChains would
|
|
30
|
+
* then synthesize "[no result captured]" placeholders, undoing the win.
|
|
31
|
+
* - Pre-turn content (earlier user turns) is left untouched — that's the
|
|
32
|
+
* multi-turn compactor's territory.
|
|
33
|
+
*/
|
|
34
|
+
export interface SubTurnCompactOptions {
|
|
35
|
+
keepRecentGroups?: number;
|
|
36
|
+
maxSummaryItems?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function compactCurrentTurnToolGroups(messages: Message[], options?: SubTurnCompactOptions): CompactResult;
|