@bubblebrain-ai/bubble 0.0.34 → 0.0.36

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.
Files changed (168) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/abort-errors.d.ts +2 -2
  3. package/dist/agent/budget-ledger.d.ts +4 -45
  4. package/dist/agent/budget-ledger.js +5 -75
  5. package/dist/agent/categories.d.ts +66 -2
  6. package/dist/agent/categories.js +120 -19
  7. package/dist/agent/child-runner.d.ts +1 -3
  8. package/dist/agent/child-runner.js +6 -37
  9. package/dist/agent/discovery-barrier.js +1 -1
  10. package/dist/agent/profiles.d.ts +1 -2
  11. package/dist/agent/profiles.js +25 -14
  12. package/dist/agent/routing-catalog.d.ts +113 -0
  13. package/dist/agent/routing-catalog.js +240 -0
  14. package/dist/agent/subagent-control.d.ts +5 -12
  15. package/dist/agent/subagent-control.js +0 -2
  16. package/dist/agent/subagent-store.d.ts +1 -1
  17. package/dist/agent/subagent-store.js +1 -3
  18. package/dist/agent/workflow/control.d.ts +21 -0
  19. package/dist/agent/workflow/control.js +50 -8
  20. package/dist/agent/workflow/runtime.d.ts +15 -2
  21. package/dist/agent/workflow/runtime.js +112 -30
  22. package/dist/agent.d.ts +82 -48
  23. package/dist/agent.js +360 -232
  24. package/dist/approval/controller.js +28 -0
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +21 -1
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +2 -1
  29. package/dist/config.d.ts +5 -3
  30. package/dist/config.js +10 -5
  31. package/dist/context/compact-llm.d.ts +2 -2
  32. package/dist/external-runtime/grok-acp.d.ts +56 -0
  33. package/dist/external-runtime/grok-acp.js +386 -0
  34. package/dist/external-runtime/grok-binary.d.ts +25 -0
  35. package/dist/external-runtime/grok-binary.js +108 -0
  36. package/dist/external-runtime/grok-errors.d.ts +7 -0
  37. package/dist/external-runtime/grok-errors.js +15 -0
  38. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  39. package/dist/external-runtime/grok-input-policy.js +52 -0
  40. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  41. package/dist/external-runtime/grok-ndjson.js +193 -0
  42. package/dist/external-runtime/grok-network.d.ts +15 -0
  43. package/dist/external-runtime/grok-network.js +81 -0
  44. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  45. package/dist/external-runtime/grok-preflight.js +194 -0
  46. package/dist/external-runtime/grok-process.d.ts +26 -0
  47. package/dist/external-runtime/grok-process.js +184 -0
  48. package/dist/external-runtime/grok-profile.d.ts +32 -0
  49. package/dist/external-runtime/grok-profile.js +419 -0
  50. package/dist/external-runtime/grok-provider.d.ts +31 -0
  51. package/dist/external-runtime/grok-provider.js +41 -0
  52. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  53. package/dist/external-runtime/grok-runtime.js +882 -0
  54. package/dist/external-runtime/index.d.ts +13 -0
  55. package/dist/external-runtime/index.js +13 -0
  56. package/dist/external-runtime/session-policy.d.ts +11 -0
  57. package/dist/external-runtime/session-policy.js +30 -0
  58. package/dist/external-runtime/types.d.ts +65 -0
  59. package/dist/external-runtime/types.js +1 -0
  60. package/dist/feishu/agent-host/approval-card.js +21 -0
  61. package/dist/feishu/agent-host/run-driver.js +19 -3
  62. package/dist/feishu/serve.js +1 -0
  63. package/dist/goal/tools.d.ts +6 -3
  64. package/dist/goal/tools.js +7 -19
  65. package/dist/main.js +114 -28
  66. package/dist/memory/db.d.ts +2 -0
  67. package/dist/memory/db.js +10 -0
  68. package/dist/memory/index.d.ts +1 -0
  69. package/dist/memory/index.js +1 -0
  70. package/dist/memory/phase1.js +14 -2
  71. package/dist/memory/phase2.js +21 -2
  72. package/dist/memory/prompts.js +1 -1
  73. package/dist/memory/session-policy.d.ts +25 -0
  74. package/dist/memory/session-policy.js +55 -0
  75. package/dist/memory/storage.d.ts +1 -0
  76. package/dist/memory/storage.js +13 -0
  77. package/dist/model-catalog.d.ts +18 -0
  78. package/dist/model-catalog.js +92 -52
  79. package/dist/model-config.d.ts +3 -1
  80. package/dist/model-config.js +11 -0
  81. package/dist/oauth/grok.d.ts +25 -0
  82. package/dist/oauth/grok.js +218 -0
  83. package/dist/oauth/index.d.ts +1 -0
  84. package/dist/oauth/index.js +1 -0
  85. package/dist/oauth/storage.d.ts +9 -0
  86. package/dist/oauth/storage.js +25 -0
  87. package/dist/orchestrator/default-hooks.js +23 -7
  88. package/dist/prompt/compose.d.ts +3 -0
  89. package/dist/prompt/compose.js +3 -0
  90. package/dist/prompt/delegation.d.ts +1 -1
  91. package/dist/prompt/delegation.js +41 -8
  92. package/dist/prompt/environment.js +0 -4
  93. package/dist/prompt/reminders.d.ts +0 -1
  94. package/dist/prompt/reminders.js +1 -11
  95. package/dist/prompt/routing.d.ts +14 -0
  96. package/dist/prompt/routing.js +71 -0
  97. package/dist/prompt/runtime.js +1 -1
  98. package/dist/prompt/task-reminders.d.ts +20 -0
  99. package/dist/prompt/task-reminders.js +73 -1
  100. package/dist/provider-ai-sdk.js +2 -2
  101. package/dist/provider-grok.d.ts +26 -0
  102. package/dist/provider-grok.js +59 -0
  103. package/dist/provider-openai-codex.d.ts +17 -1
  104. package/dist/provider-openai-codex.js +101 -29
  105. package/dist/provider-registry.d.ts +72 -0
  106. package/dist/provider-registry.js +336 -59
  107. package/dist/provider-transform.d.ts +1 -1
  108. package/dist/provider-transform.js +35 -17
  109. package/dist/provider.d.ts +3 -0
  110. package/dist/provider.js +14 -0
  111. package/dist/session-types.d.ts +19 -1
  112. package/dist/session.d.ts +1 -0
  113. package/dist/session.js +4 -0
  114. package/dist/slash-commands/commands.js +224 -14
  115. package/dist/slash-commands/types.d.ts +14 -1
  116. package/dist/system-prompt.d.ts +2 -0
  117. package/dist/tools/agent-lifecycle.d.ts +1 -9
  118. package/dist/tools/agent-lifecycle.js +42 -272
  119. package/dist/tools/bash.js +1 -1
  120. package/dist/tools/child-tools.js +2 -0
  121. package/dist/tools/index.d.ts +2 -2
  122. package/dist/tools/index.js +7 -5
  123. package/dist/tools/lsp.js +1 -0
  124. package/dist/tools/memory.d.ts +7 -2
  125. package/dist/tools/memory.js +28 -43
  126. package/dist/tools/server.js +4 -0
  127. package/dist/tools/tool-search.d.ts +1 -1
  128. package/dist/tools/tool-search.js +23 -3
  129. package/dist/tui/model-switch.d.ts +7 -0
  130. package/dist/tui/model-switch.js +15 -5
  131. package/dist/tui-ink/app.d.ts +3 -1
  132. package/dist/tui-ink/app.js +672 -243
  133. package/dist/tui-ink/approval/approval-dialog.js +37 -1
  134. package/dist/tui-ink/approval/diff-view.js +4 -4
  135. package/dist/tui-ink/approval/select.js +2 -2
  136. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  137. package/dist/tui-ink/composer-buffer.js +317 -0
  138. package/dist/tui-ink/feedback-dialog.js +2 -2
  139. package/dist/tui-ink/feishu-setup-picker.js +3 -3
  140. package/dist/tui-ink/footer.d.ts +1 -0
  141. package/dist/tui-ink/footer.js +7 -2
  142. package/dist/tui-ink/input-box.d.ts +36 -7
  143. package/dist/tui-ink/input-box.js +229 -157
  144. package/dist/tui-ink/markdown.js +11 -9
  145. package/dist/tui-ink/message-list.d.ts +5 -1
  146. package/dist/tui-ink/message-list.js +15 -2
  147. package/dist/tui-ink/model-picker.d.ts +61 -4
  148. package/dist/tui-ink/model-picker.js +464 -128
  149. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  150. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  151. package/dist/tui-ink/plan-confirm.js +1 -1
  152. package/dist/tui-ink/run.d.ts +3 -0
  153. package/dist/tui-ink/run.js +1 -1
  154. package/dist/tui-ink/subagent-view.d.ts +34 -1
  155. package/dist/tui-ink/subagent-view.js +82 -1
  156. package/dist/tui-ink/theme.d.ts +19 -2
  157. package/dist/tui-ink/theme.js +25 -8
  158. package/dist/tui-ink/welcome.d.ts +3 -2
  159. package/dist/tui-ink/welcome.js +10 -6
  160. package/dist/types.d.ts +8 -31
  161. package/dist/types.js +1 -1
  162. package/dist/variant/thinking-level.d.ts +2 -2
  163. package/dist/variant/thinking-level.js +2 -1
  164. package/dist/variant/variant-resolver.d.ts +22 -0
  165. package/dist/variant/variant-resolver.js +43 -0
  166. package/package.json +6 -2
  167. package/dist/tools/task.d.ts +0 -2
  168. package/dist/tools/task.js +0 -58
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Bubble is a terminal coding agent. It works inside a local project folder: it reads and edits files, runs commands behind configurable approval controls, searches and navigates code with language-server intelligence, browses the web, loads reusable skills, connects MCP tools, fans work out to subagents, and keeps persistent memory across sessions.
4
4
 
5
- It is provider-agnostic. Bring an API key for OpenAI, Anthropic, Google, DeepSeek, Moonshot/Kimi, Zhipu, Z.AI, MiniMax, Groq, Together, Fireworks, a local OpenAI-compatible endpoint, and more — or sign in to ChatGPT with OAuth and drive the Codex models directly.
5
+ It is provider-agnostic. Bring an API key for OpenAI, Anthropic, Google, DeepSeek, Moonshot/Kimi, Zhipu, Z.AI, MiniMax, Groq, Together, Fireworks, a local OpenAI-compatible endpoint, and more; sign in to ChatGPT with OAuth; or sign in with OAuth to use a ChatGPT or Grok subscription directly.
6
6
 
7
7
  ---
8
8
 
@@ -39,10 +39,10 @@ Start Bubble in the current directory:
39
39
  bubble
40
40
  ```
41
41
 
42
- On first launch, connect a model:
42
+ On first launch, connect a provider:
43
43
 
44
44
  - Run `/login` to sign in to ChatGPT (OAuth) and use the Codex models, or
45
- - Run `/provider` to add any other provider with an API key.
45
+ - Run `/provider` to select Grok Subscription or add a provider with an API key.
46
46
 
47
47
  Then just type what you want done. Bubble plans, edits files, and runs commands, asking for approval where the current permission mode requires it.
48
48
 
@@ -64,10 +64,30 @@ Bubble ships with a catalog of built-in providers. Configure them inside the app
64
64
 
65
65
  | How | What it does |
66
66
  | --- | --- |
67
- | `/login` | OAuth sign-in for ChatGPT; unlocks the OpenAI Codex models without an API key. |
68
- | `/provider` | Open a picker to connect, switch, add, or remove a provider. |
67
+ | `/login` | OAuth sign-in for ChatGPT or Grok Subscription; `/login grok` starts the Grok flow directly. |
68
+ | `/provider` | Open a picker to connect, switch, add, or remove a provider, including Grok Subscription. |
69
69
  | `/model` | Pick the active model and reasoning effort. |
70
70
 
71
+ ### Grok Subscription Provider
72
+
73
+ A Grok subscription works as a native model provider, exactly like ChatGPT
74
+ OAuth: sign in once in the browser and the subscription models (`grok-4.5`,
75
+ `grok-composer-2.5-fast`) drive Bubble's own agent loop — Bubble's tools,
76
+ skills, MCP servers, subagents, memory, permission modes, and approval UI all
77
+ apply as usual. `/model` selects the model and reasoning effort.
78
+
79
+ ```bash
80
+ bubble
81
+ # In the TUI: /login grok
82
+ # or choose Grok Subscription from /provider
83
+ ```
84
+
85
+ `/login grok` opens the xAI sign-in page in the default browser. If you have
86
+ already signed in with the official Grok CLI, Bubble imports that login and
87
+ skips the browser. Tokens are stored in `~/.bubble/auth.json` alongside other
88
+ provider credentials and refresh automatically. Use `/logout grok` to remove
89
+ this device's login.
90
+
71
91
  Built-in providers include OpenAI, Anthropic, Google, DeepSeek, Moonshot (CN and international), Kimi for Coding, Zhipu AI, Z.AI, Alibaba DashScope, Doubao (Volcengine Ark), MiniMax, StepFun, Groq, Together AI, Fireworks, and a `local` profile for any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, etc.).
72
92
 
73
93
  For Doubao Seed models on Volcengine Ark, run `/provider --add doubao` and paste your Ark API key. The built-in endpoint is `https://ark.cn-beijing.volces.com/api/v3` and uses Ark's Responses API. The model picker exposes `minimal`, `low`, `medium`, and `high`, defaulting to `high`; `minimal` disables Ark thinking, while the other levels enable it.
@@ -95,7 +115,7 @@ For full control — custom base URLs, self-hosted gateways, extra models, or pi
95
115
 
96
116
  ### Reasoning effort
97
117
 
98
- Models that support it expose a reasoning-effort control: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Set it with `/model <id> --reasoning-effort <level>` in the app, or at launch with `--reasoning` (medium) or `--reasoning-effort <level>`.
118
+ Models that support it expose a reasoning-effort control: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Set it with `/model <id> --reasoning-effort <level>` in the app, or at launch with `--reasoning` (medium) or `--reasoning-effort <level>`.
99
119
 
100
120
  ## Permission modes
101
121
 
@@ -191,7 +211,7 @@ Options (default):
191
211
  -r, --resume Resume a previous session (latest by default)
192
212
  --session <name> Session name to create or resume
193
213
  --reasoning Enable reasoning mode at medium effort
194
- --reasoning-effort <l> Set reasoning effort: off|minimal|low|medium|high|xhigh|max
214
+ --reasoning-effort <l> Set reasoning effort: off|minimal|low|medium|high|xhigh|max|ultra
195
215
  --plan Start in plan mode (read-only investigation; propose before executing)
196
216
  --dangerously-skip-permissions
197
217
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
@@ -7,8 +7,8 @@ export declare class AgentAbortError extends Error {
7
7
  * message strings.
8
8
  */
9
9
  export declare class SubagentAbortError extends AgentAbortError {
10
- readonly subagentReason: "interrupt" | "user_close" | "budget";
11
- constructor(message: string, subagentReason: "interrupt" | "user_close" | "budget");
10
+ readonly subagentReason: "interrupt" | "user_close";
11
+ constructor(message: string, subagentReason: "interrupt" | "user_close");
12
12
  }
13
13
  /** Shown when the model produced no user-visible content despite recovery attempts. */
14
14
  export declare const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
@@ -1,61 +1,20 @@
1
1
  import type { TokenUsage } from "../types.js";
2
- import type { SubagentTokenCap } from "./subagent-control.js";
3
2
  export interface BudgetUsageSource {
4
3
  runId: string;
5
4
  subAgentId?: string;
6
5
  }
7
- export interface BudgetSnapshot {
8
- spent: number;
9
- limit?: number;
10
- exhausted: boolean;
11
- }
12
6
  /**
13
7
  * Shared token ledger for a parent and all of its children, with per-source
14
- * accounting so the runtime can enforce per-child caps (design doc §6).
15
- * The shared pool limit is optional — both production hosts construct the
16
- * ledger without one — so per-child caps must never be derived solely from
17
- * "pool remaining"; see computeChildTokenCap.
8
+ * accounting. Pure bookkeeping: nothing is ever stopped for token usage —
9
+ * a child's only resource bound is the model context window, absorbed by
10
+ * compaction (design doc §6, revised 2026-07-06).
18
11
  */
19
12
  export declare class BudgetLedger {
20
- private readonly limit?;
21
13
  private spent;
22
14
  private readonly spentBySource;
23
- private readonly controller;
24
- constructor(limit?: number | undefined);
25
- get signal(): AbortSignal;
26
15
  recordUsage(usage: TokenUsage, source: BudgetUsageSource): void;
27
16
  /** Tokens attributed to one child (or the parent when subAgentId is omitted). */
28
17
  spentBy(subAgentId?: string): number;
29
- /** Pool tokens remaining, or undefined when the pool has no limit. */
30
- remaining(): number | undefined;
31
- get poolLimit(): number | undefined;
32
- snapshot(): BudgetSnapshot;
18
+ totalSpent(): number;
33
19
  }
34
- /** Default absolute per-child soft cap; applies even on limit-free hosts. */
35
- export declare const DEFAULT_CHILD_TOKEN_CAP = 200000;
36
- /** Share of a limited pool reserved for the parent's own turns. */
37
- export declare const PARENT_POOL_RESERVE_RATIO = 0.2;
38
- /** Hard cap sits at least this many tokens above the soft cap (≈ 2 turns). */
39
- export declare const CHILD_HARD_CAP_FLOOR = 20000;
40
- /**
41
- * Per-child token cap, fixed at dispatch (design doc §6). The soft cap is an
42
- * absolute number (config default 200k) so it is effective on limit-free
43
- * hosts; when the pool *is* limited, the fair share of what remains after the
44
- * parent's reserve further bounds it. The cap never shrinks mid-run because
45
- * siblings spawned later.
46
- */
47
- export declare function computeChildTokenCap(options: {
48
- ledger?: BudgetLedger;
49
- subAgentId: string;
50
- activeChildren: number;
51
- configCap?: number;
52
- profileMaxTokens?: number;
53
- }): SubagentTokenCap;
54
- /**
55
- * Hard cap recomputed at each turn-boundary check: at least ~2 of this
56
- * child's average turns above the soft cap, never below the absolute floor
57
- * (design doc §6 — replaces the fixed 25% ratio that could be smaller than a
58
- * single turn).
59
- */
60
- export declare function childHardCap(soft: number, avgTurnTokens: number): number;
61
20
  export declare function composeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined;
@@ -1,96 +1,26 @@
1
1
  const PARENT_SOURCE_KEY = "__parent__";
2
2
  /**
3
3
  * Shared token ledger for a parent and all of its children, with per-source
4
- * accounting so the runtime can enforce per-child caps (design doc §6).
5
- * The shared pool limit is optional — both production hosts construct the
6
- * ledger without one — so per-child caps must never be derived solely from
7
- * "pool remaining"; see computeChildTokenCap.
4
+ * accounting. Pure bookkeeping: nothing is ever stopped for token usage —
5
+ * a child's only resource bound is the model context window, absorbed by
6
+ * compaction (design doc §6, revised 2026-07-06).
8
7
  */
9
8
  export class BudgetLedger {
10
- limit;
11
9
  spent = 0;
12
10
  spentBySource = new Map();
13
- controller = new AbortController();
14
- constructor(limit) {
15
- this.limit = limit;
16
- }
17
- get signal() {
18
- return this.controller.signal;
19
- }
20
11
  recordUsage(usage, source) {
21
12
  const delta = usage.promptTokens + usage.completionTokens;
22
13
  this.spent += delta;
23
14
  const key = source.subAgentId ?? PARENT_SOURCE_KEY;
24
15
  this.spentBySource.set(key, (this.spentBySource.get(key) ?? 0) + delta);
25
- if (this.limit !== undefined && this.spent >= this.limit && !this.controller.signal.aborted) {
26
- this.controller.abort(budgetAbortError("Budget exhausted"));
27
- }
28
16
  }
29
17
  /** Tokens attributed to one child (or the parent when subAgentId is omitted). */
30
18
  spentBy(subAgentId) {
31
19
  return this.spentBySource.get(subAgentId ?? PARENT_SOURCE_KEY) ?? 0;
32
20
  }
33
- /** Pool tokens remaining, or undefined when the pool has no limit. */
34
- remaining() {
35
- if (this.limit === undefined)
36
- return undefined;
37
- return Math.max(0, this.limit - this.spent);
38
- }
39
- get poolLimit() {
40
- return this.limit;
21
+ totalSpent() {
22
+ return this.spent;
41
23
  }
42
- snapshot() {
43
- return {
44
- spent: this.spent,
45
- limit: this.limit,
46
- exhausted: this.limit !== undefined && this.spent >= this.limit,
47
- };
48
- }
49
- }
50
- /** Default absolute per-child soft cap; applies even on limit-free hosts. */
51
- export const DEFAULT_CHILD_TOKEN_CAP = 200_000;
52
- /** Share of a limited pool reserved for the parent's own turns. */
53
- export const PARENT_POOL_RESERVE_RATIO = 0.2;
54
- /** Hard cap sits at least this many tokens above the soft cap (≈ 2 turns). */
55
- export const CHILD_HARD_CAP_FLOOR = 20_000;
56
- /**
57
- * Per-child token cap, fixed at dispatch (design doc §6). The soft cap is an
58
- * absolute number (config default 200k) so it is effective on limit-free
59
- * hosts; when the pool *is* limited, the fair share of what remains after the
60
- * parent's reserve further bounds it. The cap never shrinks mid-run because
61
- * siblings spawned later.
62
- */
63
- export function computeChildTokenCap(options) {
64
- let soft = options.configCap ?? DEFAULT_CHILD_TOKEN_CAP;
65
- if (options.profileMaxTokens !== undefined && options.profileMaxTokens > 0) {
66
- soft = Math.min(soft, options.profileMaxTokens);
67
- }
68
- const limit = options.ledger?.poolLimit;
69
- if (options.ledger && limit !== undefined) {
70
- const reserve = Math.floor(limit * PARENT_POOL_RESERVE_RATIO);
71
- const available = Math.max(0, (options.ledger.remaining() ?? 0) - reserve);
72
- const share = Math.floor(available / (options.activeChildren + 1));
73
- soft = Math.max(1, Math.min(soft, share));
74
- }
75
- return {
76
- soft,
77
- hard: soft + CHILD_HARD_CAP_FLOOR,
78
- baseline: options.ledger?.spentBy(options.subAgentId) ?? 0,
79
- };
80
- }
81
- /**
82
- * Hard cap recomputed at each turn-boundary check: at least ~2 of this
83
- * child's average turns above the soft cap, never below the absolute floor
84
- * (design doc §6 — replaces the fixed 25% ratio that could be smaller than a
85
- * single turn).
86
- */
87
- export function childHardCap(soft, avgTurnTokens) {
88
- return soft + Math.max(CHILD_HARD_CAP_FLOOR, Math.ceil(avgTurnTokens * 2));
89
- }
90
- function budgetAbortError(message) {
91
- const error = new Error(message);
92
- error.name = "AbortError";
93
- return error;
94
24
  }
95
25
  export function composeAbortSignals(signals) {
96
26
  const active = signals.filter((signal) => !!signal);
@@ -1,18 +1,50 @@
1
- import type { ThinkingLevel } from "../types.js";
1
+ import type { ModelTier } from "../model-catalog.js";
2
+ import { type ThinkingLevel } from "../types.js";
2
3
  export interface AgentCategoryConfig {
3
4
  model?: string;
5
+ /**
6
+ * Resolve the model by tier within the parent provider's catalog
7
+ * (docs/model-routing-design.md §3.1). Mutually exclusive with `model`;
8
+ * `model` wins if both are set.
9
+ */
10
+ tier?: ModelTier | "inherit";
4
11
  thinkingLevel?: ThinkingLevel;
5
12
  maxConcurrent?: number;
6
13
  }
7
14
  export type AgentCategoriesConfig = Record<string, AgentCategoryConfig>;
15
+ /**
16
+ * Field-level provenance for the model-determining fields (§3.3): a value is
17
+ * "user" ONLY when the user's config object literally contained that key.
18
+ * thinkingLevel/maxConcurrent overrides carry no routing authority, so a
19
+ * partially-overridden builtin category keeps its builtin tier under the
20
+ * rank guard.
21
+ */
22
+ export interface AgentCategoryProvenance {
23
+ tierSource?: "builtin" | "user";
24
+ modelSource?: "builtin" | "user";
25
+ }
26
+ export interface MergedAgentCategory {
27
+ config: AgentCategoryConfig;
28
+ provenance: AgentCategoryProvenance;
29
+ }
8
30
  export interface ModelRoute {
9
31
  providerId: string;
10
32
  model: string;
11
33
  thinkingLevel: ThinkingLevel;
12
34
  }
35
+ /** Which precedence layer decided the model at the category level. */
36
+ export type CategoryModelSource = "builtin-tier" | "user-category";
37
+ /** Which precedence layer decided the FINAL model (§3.4, chain-tracked). */
38
+ export type RouteModelSource = "inherit" | CategoryModelSource | "profile" | "callsite";
13
39
  export interface ResolvedSubagentRoute extends ModelRoute {
14
40
  category?: string;
15
41
  inherited: boolean;
42
+ /** Set when the category layer decided the model (undefined = inherit). */
43
+ categoryModelSource?: CategoryModelSource;
44
+ /** True iff providerId+model equal the parent's — regardless of effort (§3.4). */
45
+ modelInherited?: boolean;
46
+ /** Assigned while applying the precedence chain, never inferred from final values. */
47
+ modelSource?: RouteModelSource;
16
48
  }
17
49
  export type CategoryResolution = {
18
50
  route: ResolvedSubagentRoute;
@@ -23,10 +55,42 @@ export interface ResolvedModelSelection {
23
55
  providerId: string;
24
56
  model: string | "inherit";
25
57
  }
58
+ /** Catalog entry visible to tier resolution (built by routing-catalog.ts). */
59
+ export interface TierCatalogEntry {
60
+ id: string;
61
+ tier?: ModelTier;
62
+ /** models.json user-authored ordering; wins over everything (§3.2). */
63
+ routingPriority?: number;
64
+ /** Index in the builtin catalog order, when the id is builtin. */
65
+ builtinIndex?: number;
66
+ }
67
+ /** Context for rank-guarded automatic tier routing (§3.2). */
68
+ export interface TierRoutingContext {
69
+ /** Tier of the parent's own model, if annotated. */
70
+ parentTier?: ModelTier;
71
+ /** Same-provider catalog the tier is resolved against. */
72
+ models: TierCatalogEntry[];
73
+ /** agentRouting.autoTier kill switch (default true). */
74
+ autoTier: boolean;
75
+ }
76
+ export declare const TIER_RANK: Record<ModelTier, number>;
26
77
  export declare function builtinAgentCategories(): AgentCategoriesConfig;
27
78
  export declare function mergeAgentCategories(userCategories?: AgentCategoriesConfig): AgentCategoriesConfig;
79
+ /**
80
+ * Field-level merge with provenance (§3.3). The merge itself is identical to
81
+ * mergeAgentCategories; provenance records whether the user literally wrote
82
+ * `tier` / `model`, so builtin tiers flowing through a partial override never
83
+ * masquerade as user intent.
84
+ */
85
+ export declare function mergeAgentCategoriesWithProvenance(userCategories?: AgentCategoriesConfig): Record<string, MergedAgentCategory>;
28
86
  export declare function sanitizeAgentCategories(value: unknown): AgentCategoriesConfig;
29
- export declare function resolveSubagentRoute(category: string | undefined, parent: ModelRoute, categories?: AgentCategoriesConfig): CategoryResolution;
87
+ export declare function resolveSubagentRoute(category: string | undefined, parent: ModelRoute, categories?: AgentCategoriesConfig, tierContext?: TierRoutingContext): CategoryResolution;
88
+ /**
89
+ * Deterministic within-tier ordering (§3.2): user routingPriority, then
90
+ * builtin catalog order, then normalized id — never a remote response's
91
+ * array order.
92
+ */
93
+ export declare function selectTierCandidates(models: TierCatalogEntry[], tier: ModelTier): TierCatalogEntry[];
30
94
  export declare function resolveModelRoute(model: string | undefined, parentProviderId: string): ResolvedModelSelection;
31
95
  export declare function resolveSameProviderModelRoute(model: string | undefined, parentProviderId: string): {
32
96
  model: string | "inherit";
@@ -1,16 +1,9 @@
1
- const THINKING_LEVELS = new Set([
2
- "off",
3
- "minimal",
4
- "low",
5
- "medium",
6
- "high",
7
- "xhigh",
8
- "max",
9
- ]);
1
+ import { THINKING_LEVELS } from "../types.js";
2
+ export const TIER_RANK = { fast: 0, balanced: 1, strong: 2 };
10
3
  const BUILTIN_CATEGORIES = {
11
- quick: { model: "inherit", thinkingLevel: "low", maxConcurrent: 3 },
4
+ quick: { model: "inherit", tier: "fast", thinkingLevel: "low", maxConcurrent: 3 },
5
+ explore: { model: "inherit", tier: "fast", thinkingLevel: "low", maxConcurrent: 3 },
12
6
  deep: { model: "inherit", thinkingLevel: "high", maxConcurrent: 2 },
13
- explore: { model: "inherit", thinkingLevel: "low", maxConcurrent: 3 },
14
7
  review: { model: "inherit", thinkingLevel: "high", maxConcurrent: 2 },
15
8
  frontend: { model: "inherit", thinkingLevel: "high", maxConcurrent: 1 },
16
9
  writing: { model: "inherit", thinkingLevel: "medium", maxConcurrent: 2 },
@@ -28,6 +21,36 @@ export function mergeAgentCategories(userCategories) {
28
21
  }
29
22
  return merged;
30
23
  }
24
+ /**
25
+ * Field-level merge with provenance (§3.3). The merge itself is identical to
26
+ * mergeAgentCategories; provenance records whether the user literally wrote
27
+ * `tier` / `model`, so builtin tiers flowing through a partial override never
28
+ * masquerade as user intent.
29
+ */
30
+ export function mergeAgentCategoriesWithProvenance(userCategories) {
31
+ const sanitizedUser = sanitizeAgentCategories(userCategories);
32
+ const out = {};
33
+ for (const [name, config] of Object.entries(builtinAgentCategories())) {
34
+ out[name] = {
35
+ config: { ...config },
36
+ provenance: {
37
+ tierSource: config.tier !== undefined ? "builtin" : undefined,
38
+ modelSource: config.model !== undefined && config.model !== "inherit" ? "builtin" : undefined,
39
+ },
40
+ };
41
+ }
42
+ for (const [name, config] of Object.entries(sanitizedUser)) {
43
+ const existing = out[name] ?? { config: {}, provenance: {} };
44
+ out[name] = {
45
+ config: { ...existing.config, ...config },
46
+ provenance: {
47
+ tierSource: config.tier !== undefined ? "user" : existing.provenance.tierSource,
48
+ modelSource: config.model !== undefined ? "user" : existing.provenance.modelSource,
49
+ },
50
+ };
51
+ }
52
+ return out;
53
+ }
31
54
  export function sanitizeAgentCategories(value) {
32
55
  if (!value || typeof value !== "object" || Array.isArray(value))
33
56
  return {};
@@ -41,6 +64,9 @@ export function sanitizeAgentCategories(value) {
41
64
  if (typeof record.model === "string" && record.model.trim()) {
42
65
  config.model = record.model.trim();
43
66
  }
67
+ if (isCategoryTier(record.tier)) {
68
+ config.tier = record.tier;
69
+ }
44
70
  if (isThinkingLevel(record.thinkingLevel)) {
45
71
  config.thinkingLevel = record.thinkingLevel;
46
72
  }
@@ -51,27 +77,99 @@ export function sanitizeAgentCategories(value) {
51
77
  }
52
78
  return out;
53
79
  }
54
- export function resolveSubagentRoute(category, parent, categories) {
80
+ export function resolveSubagentRoute(category, parent, categories, tierContext) {
55
81
  const normalizedCategory = normalizeCategoryName(category);
56
82
  if (!normalizedCategory) {
57
83
  return { route: { ...parent, inherited: true } };
58
84
  }
59
- const merged = mergeAgentCategories(categories);
60
- const config = merged[normalizedCategory];
61
- if (!config) {
85
+ const merged = mergeAgentCategoriesWithProvenance(categories);
86
+ const entry = merged[normalizedCategory];
87
+ if (!entry) {
62
88
  return { error: `Unknown subagent category "${normalizedCategory}".` };
63
89
  }
90
+ const { config, provenance } = entry;
91
+ // Explicit model binding wins over tier (config surface contract §3.1).
64
92
  const modelSelection = parseModelSelection(config.model, parent.providerId);
93
+ if (modelSelection.model !== "inherit") {
94
+ return {
95
+ route: {
96
+ category: normalizedCategory,
97
+ providerId: modelSelection.providerId,
98
+ model: modelSelection.model,
99
+ thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
100
+ inherited: false,
101
+ categoryModelSource: provenance.modelSource === "user" ? "user-category" : "builtin-tier",
102
+ },
103
+ };
104
+ }
105
+ // Tier binding: rank-guarded for builtin-sourced tiers, free for
106
+ // user-authored ones (§3.2).
107
+ const tierModel = resolveTierModel(config.tier, provenance.tierSource, tierContext);
108
+ if (tierModel) {
109
+ return {
110
+ route: {
111
+ category: normalizedCategory,
112
+ providerId: parent.providerId,
113
+ model: tierModel,
114
+ thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
115
+ inherited: false,
116
+ categoryModelSource: provenance.tierSource === "user" ? "user-category" : "builtin-tier",
117
+ },
118
+ };
119
+ }
65
120
  return {
66
121
  route: {
67
122
  category: normalizedCategory,
68
- providerId: modelSelection.providerId,
69
- model: modelSelection.model === "inherit" ? parent.model : modelSelection.model,
123
+ providerId: parent.providerId,
124
+ model: parent.model,
70
125
  thinkingLevel: config.thinkingLevel ?? parent.thinkingLevel,
71
- inherited: modelSelection.model === "inherit" && config.thinkingLevel === undefined,
126
+ inherited: config.thinkingLevel === undefined,
72
127
  },
73
128
  };
74
129
  }
130
+ /**
131
+ * Rank-guarded tier -> model resolution (§3.2). Returns undefined whenever
132
+ * the route must inherit: no tier context wired, kill switch off (builtin
133
+ * tiers only), unknown parent tier, missing target tier, or a target that is
134
+ * not strictly cheaper than the parent.
135
+ */
136
+ function resolveTierModel(tier, tierSource, tierContext) {
137
+ if (!tier || tier === "inherit" || !tierContext)
138
+ return undefined;
139
+ const candidates = selectTierCandidates(tierContext.models, tier);
140
+ if (candidates.length === 0)
141
+ return undefined;
142
+ if (tierSource !== "user") {
143
+ // Builtin-sourced tier: downgrade-only, enforced by rank comparison.
144
+ if (!tierContext.autoTier)
145
+ return undefined;
146
+ if (!tierContext.parentTier)
147
+ return undefined;
148
+ if (TIER_RANK[tier] >= TIER_RANK[tierContext.parentTier])
149
+ return undefined;
150
+ }
151
+ return candidates[0].id;
152
+ }
153
+ /**
154
+ * Deterministic within-tier ordering (§3.2): user routingPriority, then
155
+ * builtin catalog order, then normalized id — never a remote response's
156
+ * array order.
157
+ */
158
+ export function selectTierCandidates(models, tier) {
159
+ return models
160
+ .filter((entry) => entry.tier === tier)
161
+ .sort((a, b) => {
162
+ const priorityA = a.routingPriority ?? Number.POSITIVE_INFINITY;
163
+ const priorityB = b.routingPriority ?? Number.POSITIVE_INFINITY;
164
+ if (priorityA !== priorityB)
165
+ return priorityA - priorityB;
166
+ const builtinA = a.builtinIndex ?? Number.POSITIVE_INFINITY;
167
+ const builtinB = b.builtinIndex ?? Number.POSITIVE_INFINITY;
168
+ if (builtinA !== builtinB)
169
+ return builtinA - builtinB;
170
+ return a.id.localeCompare(b.id);
171
+ });
172
+ }
75
173
  export function resolveModelRoute(model, parentProviderId) {
76
174
  return parseModelSelection(model, parentProviderId);
77
175
  }
@@ -95,7 +193,10 @@ function parseModelSelection(model, parentProviderId) {
95
193
  return { providerId: parentProviderId, model };
96
194
  }
97
195
  function isThinkingLevel(value) {
98
- return typeof value === "string" && THINKING_LEVELS.has(value);
196
+ return typeof value === "string" && THINKING_LEVELS.includes(value);
197
+ }
198
+ function isCategoryTier(value) {
199
+ return value === "fast" || value === "balanced" || value === "strong" || value === "inherit";
99
200
  }
100
201
  function cloneCategories(categories) {
101
202
  return Object.fromEntries(Object.entries(categories).map(([name, config]) => [name, { ...config }]));
@@ -8,7 +8,6 @@
8
8
  * turn-boundary budget enforcement, the handoff completeness guard, and the
9
9
  * mapping of failures to SubagentFinalReason.
10
10
  */
11
- import { BudgetLedger } from "./budget-ledger.js";
12
11
  import type { SubagentRunOutcome } from "./subagent-scheduler.js";
13
12
  import type { SubagentFinalReason, SubagentThreadRecord } from "./subagent-control.js";
14
13
  import type { AgentEvent, Message, ToolRegistryEntry, ToolResult, ToolUpdate } from "../types.js";
@@ -24,7 +23,6 @@ export interface ChildRunOptions {
24
23
  }
25
24
  export interface ChildRunnerHost {
26
25
  allTools(): ToolRegistryEntry[];
27
- budgetLedger(): BudgetLedger | undefined;
28
26
  emit(record: SubagentThreadRecord, options: ChildRunOptions, status: ToolUpdate["status"], event?: AgentEvent, message?: string): void;
29
27
  runLifecycleHook(record: SubagentThreadRecord, cwd: string, eventName: "SubagentStart" | "SubagentStop", status?: string, error?: string, abortSignal?: AbortSignal): Promise<void>;
30
28
  finalizeBlocked(record: SubagentThreadRecord, error: string, options: ChildRunOptions): void;
@@ -47,7 +45,7 @@ export declare function sanitizeSubagentSummary(value: string): string;
47
45
  * to a trivial question is a complete handoff.
48
46
  */
49
47
  export declare function needsExplicitFinalSummary(record: SubagentThreadRecord, executedAnyTool: boolean): boolean;
50
- export declare function classifySubagentAbortReason(reason: unknown, parentSignal: AbortSignal | undefined, ledger: BudgetLedger | undefined): SubagentFinalReason;
48
+ export declare function classifySubagentAbortReason(reason: unknown, parentSignal: AbortSignal | undefined): SubagentFinalReason;
51
49
  /**
52
50
  * Drops trailing "[model request interrupted ...]" boundary messages so a
53
51
  * rate-limit re-entry resumes from clean history (design §4.5).
@@ -9,7 +9,7 @@
9
9
  * mapping of failures to SubagentFinalReason.
10
10
  */
11
11
  import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./abort-errors.js";
12
- import { childHardCap, composeAbortSignals } from "./budget-ledger.js";
12
+ import { composeAbortSignals } from "./budget-ledger.js";
13
13
  import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "../provider-artifacts.js";
14
14
  import { isRateLimitError } from "../network/errors.js";
15
15
  import { isProviderTransportError } from "../network/provider-transport.js";
@@ -68,13 +68,6 @@ export class ChildRunner {
68
68
  let turnSummaryBuffer = "";
69
69
  let turnHadToolCall = false;
70
70
  let executedAnyTool = false;
71
- // Per-child budget enforcement happens at turn boundaries (design §6):
72
- // turn_end already carries usage, and a reminder injected here is seen by
73
- // the very next provider call — unlike chunk-level aborts.
74
- const cap = record.tokenCap;
75
- let runTokens = 0;
76
- let runTurns = 0;
77
- let budgetSoftWarned = false;
78
71
  // Re-entry after a rate limit: the input was applied on attempt 1, so the
79
72
  // child history must not gain a second copy, and any stale interruption
80
73
  // boundary from the failed call is stripped (design §4.5).
@@ -110,21 +103,6 @@ export class ChildRunner {
110
103
  }
111
104
  if (event.type === "turn_end" && event.usage) {
112
105
  record.usage = mergeUsage(record.usage, event.usage);
113
- runTokens += event.usage.promptTokens + event.usage.completionTokens;
114
- runTurns += 1;
115
- if (cap) {
116
- if (!budgetSoftWarned && runTokens >= cap.soft) {
117
- budgetSoftWarned = true;
118
- // The hard cap is fixed when the warning fires: soft + ~2 of
119
- // this child's average turns (absolute floor), so the child
120
- // gets a real chance to wrap up before the kill (design §6).
121
- cap.hard = childHardCap(cap.soft, runTokens / Math.max(1, runTurns));
122
- subAgent.injectSystemReminder(buildChildBudgetWrapUpReminder(runTokens, cap.soft));
123
- }
124
- else if (budgetSoftWarned && runTokens >= cap.hard) {
125
- record.abortController.abort(new SubagentAbortError(`Subagent ${record.agentId} exceeded its hard token cap (${cap.hard}).`, "budget"));
126
- }
127
- }
128
106
  }
129
107
  if (event.type === "turn_end") {
130
108
  const turnSummary = stripProviderProtocolArtifacts(turnSummaryBuffer).trim();
@@ -176,7 +154,7 @@ export class ChildRunner {
176
154
  const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
177
155
  record.status = cancelled ? "cancelled" : "failed";
178
156
  record.finalReason = cancelled
179
- ? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal, this.host.budgetLedger())
157
+ ? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal)
180
158
  : "failed_transient";
181
159
  record.summary = sanitizeSubagentSummary(record.summary);
182
160
  record.error = error?.message || String(error);
@@ -267,23 +245,21 @@ export function needsExplicitFinalSummary(record, executedAnyTool) {
267
245
  return false;
268
246
  if (record.summary === EMPTY_ASSISTANT_FALLBACK)
269
247
  return true;
270
- if (estimateHandoffTokens(record.summary) < HANDOFF_TOKEN_FLOOR)
248
+ // A schema-bearing child's handoff is a compact JSON value; short is
249
+ // complete by construction, and a restate turn would risk breaking the JSON.
250
+ if (!record.expectsStructuredOutput && estimateHandoffTokens(record.summary) < HANDOFF_TOKEN_FLOOR)
271
251
  return true;
272
252
  return isIntermediateHandoff(record.summary);
273
253
  }
274
- export function classifySubagentAbortReason(reason, parentSignal, ledger) {
254
+ export function classifySubagentAbortReason(reason, parentSignal) {
275
255
  if (reason instanceof SubagentAbortError) {
276
256
  switch (reason.subagentReason) {
277
257
  case "interrupt":
278
258
  return "cancelled_interrupt";
279
259
  case "user_close":
280
260
  return "cancelled_user";
281
- case "budget":
282
- return "cancelled_budget";
283
261
  }
284
262
  }
285
- if (ledger?.snapshot().exhausted)
286
- return "cancelled_budget";
287
263
  if (parentSignal?.aborted)
288
264
  return "cancelled_parent_abort";
289
265
  return "cancelled_user";
@@ -302,13 +278,6 @@ export function stripTrailingModelInterruptedBoundary(messages) {
302
278
  break;
303
279
  }
304
280
  }
305
- function buildChildBudgetWrapUpReminder(spentTokens, softCap) {
306
- return [
307
- `Token budget notice: this subagent has used ~${Math.round(spentTokens)} tokens, crossing its ${softCap}-token budget.`,
308
- "Wrap up now: stop opening new lines of investigation and produce your complete final handoff",
309
- "(findings, conclusions, unfinished items) in your next message.",
310
- ].join(" ");
311
- }
312
281
  export function summarizeSubagentToolEnd(event) {
313
282
  const metadata = (event.result.metadata ?? {});
314
283
  const reason = readString(metadata.reason);