@bubblebrain-ai/bubble 0.0.4 → 0.0.6

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 (91) hide show
  1. package/dist/agent/budget-ledger.d.ts +20 -0
  2. package/dist/agent/budget-ledger.js +51 -0
  3. package/dist/agent/execution-governor.js +1 -1
  4. package/dist/agent/profiles.d.ts +59 -0
  5. package/dist/agent/profiles.js +460 -0
  6. package/dist/agent/subagent-control.d.ts +52 -0
  7. package/dist/agent/subagent-control.js +38 -0
  8. package/dist/agent/task-size.d.ts +9 -0
  9. package/dist/agent/task-size.js +33 -0
  10. package/dist/agent/tool-intent.d.ts +1 -0
  11. package/dist/agent/tool-intent.js +1 -1
  12. package/dist/agent.d.ts +60 -1
  13. package/dist/agent.js +648 -55
  14. package/dist/context/budget.js +1 -0
  15. package/dist/context/compact-llm.js +7 -6
  16. package/dist/context/compact.js +6 -6
  17. package/dist/context/projector.d.ts +3 -3
  18. package/dist/context/projector.js +32 -18
  19. package/dist/context/prune.d.ts +2 -2
  20. package/dist/context/prune.js +1 -4
  21. package/dist/main.js +12 -5
  22. package/dist/mcp/manager.js +1 -0
  23. package/dist/orchestrator/default-hooks.js +85 -35
  24. package/dist/orchestrator/hooks.d.ts +5 -3
  25. package/dist/prompt/compose.d.ts +1 -0
  26. package/dist/prompt/compose.js +11 -1
  27. package/dist/prompt/environment.js +23 -2
  28. package/dist/prompt/provider-prompts/deepseek.js +1 -2
  29. package/dist/prompt/provider-prompts/kimi.js +1 -2
  30. package/dist/prompt/reminders.d.ts +21 -2
  31. package/dist/prompt/reminders.js +53 -8
  32. package/dist/prompt/runtime.d.ts +1 -1
  33. package/dist/prompt/runtime.js +17 -23
  34. package/dist/provider-artifacts.d.ts +7 -0
  35. package/dist/provider-artifacts.js +60 -0
  36. package/dist/provider.d.ts +16 -8
  37. package/dist/provider.js +149 -34
  38. package/dist/session-log.js +3 -1
  39. package/dist/system-prompt.d.ts +2 -0
  40. package/dist/tools/agent-lifecycle.d.ts +6 -0
  41. package/dist/tools/agent-lifecycle.js +355 -0
  42. package/dist/tools/bash.d.ts +2 -1
  43. package/dist/tools/bash.js +3 -1
  44. package/dist/tools/edit-apply.d.ts +25 -0
  45. package/dist/tools/edit-apply.js +228 -0
  46. package/dist/tools/edit.d.ts +2 -1
  47. package/dist/tools/edit.js +75 -56
  48. package/dist/tools/exit-plan-mode.js +3 -1
  49. package/dist/tools/file-mutation-queue.d.ts +1 -0
  50. package/dist/tools/file-mutation-queue.js +32 -0
  51. package/dist/tools/file-state.d.ts +25 -0
  52. package/dist/tools/file-state.js +52 -0
  53. package/dist/tools/glob.js +1 -0
  54. package/dist/tools/grep.js +1 -0
  55. package/dist/tools/index.d.ts +3 -1
  56. package/dist/tools/index.js +9 -7
  57. package/dist/tools/lsp.js +2 -0
  58. package/dist/tools/memory.js +2 -0
  59. package/dist/tools/question.js +2 -0
  60. package/dist/tools/read.d.ts +2 -1
  61. package/dist/tools/read.js +6 -1
  62. package/dist/tools/skill.js +1 -0
  63. package/dist/tools/task.js +1 -0
  64. package/dist/tools/todo.js +1 -0
  65. package/dist/tools/tool-search.js +2 -1
  66. package/dist/tools/web-fetch.js +1 -0
  67. package/dist/tools/web-search.js +1 -0
  68. package/dist/tools/write.d.ts +4 -3
  69. package/dist/tools/write.js +135 -54
  70. package/dist/tui/display-history.d.ts +10 -1
  71. package/dist/tui/markdown-inline.d.ts +22 -0
  72. package/dist/tui/markdown-inline.js +68 -0
  73. package/dist/tui/render-signature.d.ts +1 -0
  74. package/dist/tui/render-signature.js +7 -0
  75. package/dist/tui/run.js +811 -274
  76. package/dist/tui/streaming-tool-args.d.ts +15 -0
  77. package/dist/tui/streaming-tool-args.js +30 -0
  78. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  79. package/dist/tui/tool-renderers/fallback.js +75 -0
  80. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  81. package/dist/tui/tool-renderers/registry.js +11 -0
  82. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  83. package/dist/tui/tool-renderers/subagent.js +114 -0
  84. package/dist/tui/tool-renderers/types.d.ts +36 -0
  85. package/dist/tui/tool-renderers/types.js +1 -0
  86. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  87. package/dist/tui/tool-renderers/write-preview.js +30 -0
  88. package/dist/tui/tool-renderers/write.d.ts +6 -0
  89. package/dist/tui/tool-renderers/write.js +88 -0
  90. package/dist/types.d.ts +105 -10
  91. package/package.json +1 -1
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * System reminders - short, runtime-variable instructions injected into the
3
- * message stream as <system-reminder>-wrapped user messages with isMeta=true.
3
+ * message stream as hidden meta messages.
4
4
  *
5
5
  * Rationale: the static system prompt is stable and cacheable. Mode transitions
6
6
  * and other ephemeral state are signaled via reminders so we do not invalidate
7
7
  * the prompt cache every time something changes.
8
8
  */
9
9
  export function wrapInSystemReminder(content) {
10
- return `<system-reminder>\n${content.trim()}\n</system-reminder>`;
10
+ return content.trim();
11
11
  }
12
12
  export function isPermissionModeReminder(content) {
13
13
  if (typeof content !== "string")
@@ -20,7 +20,7 @@ const PLAN_MODE_ENTER = `
20
20
  Plan mode is now ACTIVE.
21
21
 
22
22
  Rules while in plan mode:
23
- - Only read-only tools are allowed, including read, glob, grep, lsp, web_search, web_fetch, task, skill, todo_write, tool_search, question, and exit_plan_mode.
23
+ - Only read-only tools are allowed, including read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill, todo_write, tool_search, question, and exit_plan_mode.
24
24
  - Writes, edits, and shell commands WILL be rejected by the harness; do not try them.
25
25
  - Do not edit files or claim implementation is complete while plan mode is active.
26
26
  - Investigate the codebase, then use the question tool to clarify important ambiguities, tradeoffs, requirements, or preference choices that would materially change the plan.
@@ -55,7 +55,6 @@ export function reminderForMode(mode) {
55
55
  return wrapInSystemReminder(DEFAULT_ENTER);
56
56
  }
57
57
  }
58
- // Backward-compat exports kept in case external code pinned the old names.
59
58
  export const PLAN_MODE_ENTER_REMINDER = reminderForMode("plan");
60
59
  export const PLAN_MODE_EXIT_REMINDER = reminderForMode("default");
61
60
  /**
@@ -172,13 +171,59 @@ Treat the task output as a bounded subtask result:
172
171
  - do not re-run the same exploratory search unless the subtask uncovered a concrete contradiction
173
172
  `);
174
173
  }
175
- export function buildVerificationReminder(reason) {
174
+ // Removed: buildVerificationReminder / buildVerificationFailureReminder.
175
+ // The verification reminder ladder pressured the model to run a "verification"
176
+ // after every file change. For models with hex-tokenization blind spots (e.g.
177
+ // DeepSeek v4-pro), this triggered death loops where the model wrote ad-hoc
178
+ // validation scripts that found the bug but could never fix it. CC trusts the
179
+ // model to decide when verification is meaningful; we follow that.
180
+ /**
181
+ * Fired when the same edit/write tool call (identical tool name + args) has
182
+ * just failed for the second time in a row. Models — especially thinking-heavy
183
+ * ones — can otherwise spiral on `No changes made: identical content` or
184
+ * `oldText not found` because their internal reasoning convinces them they
185
+ * are typing the change correctly even though the JSON args arrive identical.
186
+ * This nudge forces a strategy change.
187
+ */
188
+ export function buildEditRetryEscalationReminder(reason) {
176
189
  return wrapInSystemReminder(`
177
- Verification required before final answer.
190
+ The same edit/write call has failed twice with identical arguments.
178
191
 
179
192
  ${reason}
180
193
 
181
- You have changed files in this turn. Run the narrowest meaningful verification command or runtime check before finalizing.
182
- If verification truly cannot be run, state the concrete blocker and the residual risk.
194
+ Stop retrying the same call. Pick one of:
195
+ - Re-read the target file and compare the actual bytes to your intended oldText / newText. Trailing whitespace, unicode lookalikes, or off-by-one boundaries are common causes.
196
+ - If you intended to add a single character (e.g. fixing a 5-digit hex color to 6 digits), confirm that your newText string actually contains the added character before sending again.
197
+ - Use the write tool with overwrite=true and the full new content instead of edit — useful when the change spans many lines or the diff anchor is ambiguous. Existing files must be read or modified in this session before full-file replacement.
198
+ - If you cannot determine the cause, ask the user for clarification.
199
+ `);
200
+ }
201
+ /**
202
+ * Fired the FIRST time the model re-reads a file it already read in this turn.
203
+ * Soft — does not freeze the tool. Just prevents a 3rd / 4th re-read.
204
+ */
205
+ export function buildRedundantReadReminder(path) {
206
+ return wrapInSystemReminder(`
207
+ You already read ${path} earlier in this turn. Use the content already in context rather than re-reading.
208
+ Only re-read this path if a subsequent tool call (edit/write/bash) modified it since.
209
+ `);
210
+ }
211
+ /**
212
+ * Injected once at task start when the user's input looks like a small,
213
+ * focused task (e.g. "write an HTML page about X"). Counterweight to the
214
+ * default protocol which biases toward thorough exploration.
215
+ */
216
+ export function buildSmallTaskHint() {
217
+ return wrapInSystemReminder(`
218
+ This appears to be a small, focused task (short request, single deliverable, no integration ambiguity).
219
+
220
+ Prefer direct execution over exploration:
221
+ - If the target file path is given or obvious, use write/edit directly.
222
+ - Do not glob, read, or grep adjacent files unless the request explicitly references them.
223
+ - Do not pre-plan with todo_write for tasks that can be done in one or two tool calls.
224
+ - Skip the "investigate the codebase" step that applies to larger changes.
183
225
  `);
184
226
  }
227
+ // Removed: buildFinalizeOpportunityReminder. Was paired with the verification
228
+ // nag ladder. Without the ladder, "you can finalize now" advice is redundant —
229
+ // the model finalises whenever its own judgement says the task is done.
@@ -3,7 +3,7 @@ export interface RuntimePromptOptions {
3
3
  thinkingLevel?: ThinkingLevel;
4
4
  /**
5
5
  * Kept for API compatibility. Agent mode is no longer baked into the static
6
- * system prompt — mode changes are signalled via <system-reminder> injections
6
+ * system prompt — mode changes are signalled via hidden runtime reminders
7
7
  * (see src/prompt/reminders.ts) so the base prompt stays stable for caching.
8
8
  */
9
9
  mode?: PermissionMode;
@@ -1,30 +1,24 @@
1
+ // Compact, prose-shaped guidelines. Each line is one rule. The set is kept
2
+ // short on purpose: a thinking-heavy model burns reasoning tokens on every
3
+ // rule it has to weigh per turn, and most behaviors should be background
4
+ // disposition, not active checklist items. Add to this list only when an
5
+ // observed failure cannot be addressed by an existing rule.
1
6
  const defaultGuidelines = [
2
- "Inspect relevant files, command output, or runtime state before making claims about code behavior",
3
- "Separate confirmed facts from inference when the evidence is incomplete",
4
- "Prefer runtime and call-chain evidence over README text or configuration names for behavior questions",
5
- "Before editing or writing files, read them first if they exist",
6
- "Use edit for targeted changes to existing files; use write for creating new files",
7
- "Edit only the files required for the requested change",
8
- "Prefer structured search tools over bash for repository searches whenever possible",
9
- "Do not repeat near-identical searches when they are not producing new evidence",
10
- "When investigating configuration or security questions, stop once the relevant load path, storage path, and exposure path are identified",
11
- "Use the task tool for bounded investigative subproblems instead of letting the main loop churn on repeated exploratory searches",
12
- "After code edits, run the narrowest meaningful verification command or explain why verification is not possible",
13
- "When finishing a coding task, report what changed, where it changed, verification results, and remaining risk",
14
- "Be concise in your responses",
7
+ "Ground decisions in the codebase: inspect relevant files, command output, or runtime state before making claims about behavior. Separate confirmed facts from inference when evidence is incomplete.",
8
+ "Choose the smallest coherent change. Edit only the files required for the requested change; do not refactor or improve adjacent code unprompted.",
9
+ "For modifications to existing code, read the file first. For brand-new files whose target path is known and does not exist, write directly without exploratory reading. Use edit for small targeted changes; use write with overwrite=true for intentional full-file replacement of an existing file. Never delete and recreate a file just to overwrite it.",
10
+ "Prefer structured tools (glob, grep, lsp, read) over bash for search and inspection. Do not repeat a near-identical search or re-read the same file unless new evidence changes the question.",
11
+ "If a tool fails, diagnose the error before switching tactics. Do not retry the identical call with identical arguments. After two equivalent failures, switch approach — re-read the file, use a different tool, rewrite the whole file with write overwrite=true, or ask the user.",
12
+ "Before reporting a task complete, verify it works when verification is meaningful and cheap — run the existing test, execute the script, check the output. If no test exists, the change is purely declarative (static HTML/markdown/config), or running the code is not practical, state that explicitly rather than inventing a verification step. Do not write throwaway validation scripts to prove correctness; if there is no real check to run, report the change and stop.",
15
13
  ];
16
14
  export function buildRuntimePrompt(options = {}) {
17
- const thinkingLevel = options.thinkingLevel ?? "off";
18
15
  const guidelines = dedupe(defaultGuidelines, options.guidelines ?? []);
19
- return `Current thinking level: ${thinkingLevel}
20
-
21
- Execution protocol:
22
- 1. Understand the user's requested outcome and current constraints.
23
- 2. Inspect the relevant files or state before making claims or edits.
24
- 3. Choose the smallest coherent change that solves the actual problem.
25
- 4. Edit only the necessary files.
26
- 5. Verify with the narrowest meaningful command or runtime check when possible.
27
- 6. Finish with changed files, verification results, and unresolved risk.
16
+ // The execution flow is stated as a single prose sentence rather than a
17
+ // numbered protocol. Numbered checklists prompt thinking models to walk
18
+ // each step explicitly in their reasoning every turn, even for trivial
19
+ // tasks multiplying latency without improving quality. Prose lets the
20
+ // protocol act as background disposition.
21
+ return `Work by understanding the requested outcome, grounding decisions in the codebase, making the smallest coherent change, and verifying when possible. Scale your effort to the task: a one-file create-or-edit deserves direct execution, not extensive pre-exploration.
28
22
 
29
23
  Guidelines:
30
24
  ${guidelines.map((item) => `- ${item}`).join("\n")}`;
@@ -0,0 +1,7 @@
1
+ export declare function stripProviderProtocolArtifacts(text: string): string;
2
+ export declare function isOnlyProviderProtocolArtifacts(text: string): boolean;
3
+ export interface ProviderProtocolArtifactFilter {
4
+ push(text: string): string;
5
+ flush(): string;
6
+ }
7
+ export declare function createProviderProtocolArtifactFilter(): ProviderProtocolArtifactFilter;
@@ -0,0 +1,60 @@
1
+ // Models with non-OpenAI chat templates (GLM-4.5/4.6, DeepSeek, some Kimi builds)
2
+ // emit tool-call delimiters as inline assistant text instead of as structured
3
+ // tool_calls deltas. The shapes vary — `<|tool_call|>`, `<||DSML||tool_calls>`,
4
+ // `<||DSML||invoke name="x">`, closing `</||DSML||tool_calls>`, etc. — but
5
+ // they always share the pattern of a tag whose name is wrapped in `|` or `|`.
6
+ // If we let any of that text reach the consumer it pollutes the streamed
7
+ // assistant text and, downstream, the subagent's summary field.
8
+ const TOOL_PROTOCOL_PATTERNS = [
9
+ // Generic: opening or closing tag whose name is wrapped in `|` or `|`,
10
+ // optionally with attributes after the closing pipe (e.g. `invoke name="x"`).
11
+ /<\/?\s*[||]+[^<>]*?[||]+[^<>]*>/g,
12
+ // Plain ASCII variants without attributes.
13
+ /<\/?\|tool_calls?\|>/gi,
14
+ ];
15
+ export function stripProviderProtocolArtifacts(text) {
16
+ let out = text;
17
+ for (const pattern of TOOL_PROTOCOL_PATTERNS) {
18
+ out = out.replace(pattern, "");
19
+ }
20
+ return out;
21
+ }
22
+ export function isOnlyProviderProtocolArtifacts(text) {
23
+ return !!text.trim() && stripProviderProtocolArtifacts(text).trim().length === 0;
24
+ }
25
+ export function createProviderProtocolArtifactFilter() {
26
+ let pending = "";
27
+ return {
28
+ push(text) {
29
+ pending = stripProviderProtocolArtifacts(pending + text);
30
+ const keep = trailingPossibleMarkerLength(pending);
31
+ const emit = pending.slice(0, pending.length - keep);
32
+ pending = pending.slice(pending.length - keep);
33
+ return emit;
34
+ },
35
+ flush() {
36
+ const out = stripProviderProtocolArtifacts(pending);
37
+ pending = "";
38
+ return out;
39
+ },
40
+ };
41
+ }
42
+ // Hold back a trailing fragment if it could be the start of a pipe-wrapped tag
43
+ // whose closing `>` hasn't arrived yet. Without this guard, a stream that flushes
44
+ // mid-tag (`<` ... `|DSML|tool_calls`) would emit the partial tag as text, then
45
+ // emit the rest later — the stripping regex only matches complete tags.
46
+ function trailingPossibleMarkerLength(text) {
47
+ const lastLt = text.lastIndexOf("<");
48
+ if (lastLt === -1)
49
+ return 0;
50
+ const tail = text.slice(lastLt);
51
+ if (tail.includes(">"))
52
+ return 0;
53
+ // Hold back only when the trailing fragment looks like the start of a protocol
54
+ // tag. Anything else (e.g. `if (x < y)` in source) flushes immediately.
55
+ if (/^<\/?$/.test(tail))
56
+ return tail.length;
57
+ if (/^<\/?\s*[||]/.test(tail))
58
+ return tail.length;
59
+ return 0;
60
+ }
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
5
5
  */
6
- import type { Message, Provider, StreamChunk, ThinkingLevel } from "./types.js";
6
+ import type { Provider, ProviderMessage, StreamChunk, ThinkingLevel } from "./types.js";
7
7
  type ReasoningContentEcho = "tool_calls" | "all";
8
- export declare function toChatCompletionsMessage(message: Message, options?: {
8
+ export type ToolArgsMergeMode = "delta" | "snapshot";
9
+ export interface TranslateOpenAIStreamOptions {
10
+ toolArgsMergeMode?: ToolArgsMergeMode;
11
+ }
12
+ export declare function toChatCompletionsMessage(message: ProviderMessage, options?: {
9
13
  reasoningContentEcho?: ReasoningContentEcho;
10
14
  }): Record<string, unknown>;
11
15
  export interface ProviderInstanceOptions {
@@ -17,15 +21,19 @@ export interface ProviderInstanceOptions {
17
21
  }
18
22
  export declare function createUnavailableProvider(message: string): Provider;
19
23
  export declare function createProviderInstance(options: ProviderInstanceOptions): Provider;
24
+ export interface NormalizedToolArgs {
25
+ args: string;
26
+ corrupt: boolean;
27
+ }
28
+ export declare function normalizeToolArgsDetailed(raw: string): NormalizedToolArgs;
20
29
  export declare function normalizeToolArgs(raw: string): string;
21
30
  /**
22
31
  * Convert an OpenAI-compatible chat-completions stream into our internal StreamChunk events.
23
32
  *
24
- * Multi-tool-call streams are buffered by `index` and emitted in index order at
25
- * `finish_reason === "tool_calls"`, so the agent layer always sees a clean
26
- * (isStart -> args -> isEnd) sequence per call. This matters for providers like
27
- * Kimi K2.5 that emit several parallel tool calls per assistant turn -- the
28
- * previous single-slot implementation silently dropped every call but the last.
33
+ * Multi-tool-call streams are tracked by `index`, but tool-call starts and
34
+ * argument deltas are emitted as soon as they arrive so the TUI can render
35
+ * partial write previews before the tool executes. End events are still flushed
36
+ * in index order to keep multi-call turns deterministic.
29
37
  */
30
- export declare function translateOpenAIStream(stream: AsyncIterable<any>): AsyncIterable<StreamChunk>;
38
+ export declare function translateOpenAIStream(stream: AsyncIterable<any>, options?: TranslateOpenAIStreamOptions): AsyncIterable<StreamChunk>;
31
39
  export {};
package/dist/provider.js CHANGED
@@ -4,8 +4,25 @@
4
4
  * Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
5
5
  */
6
6
  import OpenAI from "openai";
7
+ import { appendFileSync } from "node:fs";
7
8
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
9
+ import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
8
10
  import { resolveProviderRequestConfig } from "./provider-transform.js";
11
+ // Diagnostic logger for tool-args byte-loss investigation. Activate with
12
+ // BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
13
+ // Each line is a JSON record describing a transition. When debugging is off,
14
+ // the function is a no-op and free.
15
+ const TOOL_ARGS_DEBUG_PATH = process.env.BUBBLE_DEBUG_TOOL_ARGS?.trim();
16
+ function debugToolArgs(event) {
17
+ if (!TOOL_ARGS_DEBUG_PATH)
18
+ return;
19
+ try {
20
+ appendFileSync(TOOL_ARGS_DEBUG_PATH, JSON.stringify({ t: Date.now(), ...event }) + "\n", "utf-8");
21
+ }
22
+ catch {
23
+ // Diagnostic failures must not affect the model session.
24
+ }
25
+ }
9
26
  export function toChatCompletionsMessage(message, options = {}) {
10
27
  const reasoningContentEcho = options.reasoningContentEcho ?? "tool_calls";
11
28
  if (message.role === "assistant") {
@@ -90,7 +107,9 @@ export function createProviderInstance(options) {
90
107
  const stream = (await client.chat.completions.create(body, {
91
108
  signal: chatOptions.abortSignal,
92
109
  }));
93
- yield* translateOpenAIStream(stream);
110
+ yield* translateOpenAIStream(stream, {
111
+ toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
112
+ });
94
113
  yield { type: "done" };
95
114
  }
96
115
  async function complete(messages, chatOptions) {
@@ -117,19 +136,16 @@ export function createProviderInstance(options) {
117
136
  }
118
137
  return { streamChat, complete };
119
138
  }
120
- // Some providers (notably Fireworks-hosted Kimi) stream tool-call arguments
121
- // as repeated full snapshots in each delta instead of incremental chunks, so
122
- // a naive `+=` produces `{"x":1}{"x":1}` — not valid JSON. Parse the raw
123
- // stream; if it doesn't parse but contains a balanced `{…}` prefix or suffix
124
- // that does, use that. Empty or unsalvageable input becomes `"{}"` so the
125
- // downstream echo to the model is always valid JSON.
126
- export function normalizeToolArgs(raw) {
139
+ export function normalizeToolArgsDetailed(raw) {
127
140
  const s = (raw ?? "").trim();
128
- if (!s)
129
- return "{}";
141
+ if (!s) {
142
+ debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "empty" });
143
+ return { args: "{}", corrupt: false };
144
+ }
130
145
  try {
131
146
  JSON.parse(s);
132
- return s;
147
+ debugToolArgs({ stage: "normalize", input: raw, output: s, reason: "passthrough" });
148
+ return { args: s, corrupt: false };
133
149
  }
134
150
  catch { }
135
151
  const firstBrace = extractBalancedJson(s, 0);
@@ -138,21 +154,39 @@ export function normalizeToolArgs(raw) {
138
154
  JSON.parse(firstBrace);
139
155
  }
140
156
  catch {
141
- return "{}";
157
+ debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "first-brace-unparseable", firstBrace });
158
+ return { args: "{}", corrupt: true };
142
159
  }
143
160
  // If the content after the first balanced object is another valid object
144
161
  // with the same parse, we've got a snapshot duplication — keep one copy.
145
162
  const rest = s.slice(firstBrace.length).trim();
146
- if (!rest)
147
- return firstBrace;
163
+ if (!rest) {
164
+ debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "single-brace" });
165
+ return { args: firstBrace, corrupt: false };
166
+ }
148
167
  try {
149
168
  JSON.parse(rest);
150
- return firstBrace;
169
+ debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "snapshot-dedup", rest });
170
+ return { args: firstBrace, corrupt: false };
151
171
  }
152
172
  catch { }
153
- return firstBrace;
173
+ debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "trailing-junk-dropped", rest });
174
+ return { args: firstBrace, corrupt: false };
154
175
  }
155
- return "{}";
176
+ debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "no-balanced-json" });
177
+ return { args: "{}", corrupt: true };
178
+ }
179
+ export function normalizeToolArgs(raw) {
180
+ return normalizeToolArgsDetailed(raw).args;
181
+ }
182
+ function resolveToolArgsMergeMode(providerId, baseURL) {
183
+ const id = providerId.toLowerCase();
184
+ const url = baseURL.toLowerCase();
185
+ // Fireworks-hosted Kimi has been observed to stream cumulative snapshots
186
+ // rather than OpenAI-style argument deltas.
187
+ if (id === "fireworks" || url.includes("fireworks.ai"))
188
+ return "snapshot";
189
+ return "delta";
156
190
  }
157
191
  function extractBalancedJson(s, start) {
158
192
  if (s[start] !== "{")
@@ -189,14 +223,21 @@ function extractBalancedJson(s, start) {
189
223
  /**
190
224
  * Convert an OpenAI-compatible chat-completions stream into our internal StreamChunk events.
191
225
  *
192
- * Multi-tool-call streams are buffered by `index` and emitted in index order at
193
- * `finish_reason === "tool_calls"`, so the agent layer always sees a clean
194
- * (isStart -> args -> isEnd) sequence per call. This matters for providers like
195
- * Kimi K2.5 that emit several parallel tool calls per assistant turn -- the
196
- * previous single-slot implementation silently dropped every call but the last.
226
+ * Multi-tool-call streams are tracked by `index`, but tool-call starts and
227
+ * argument deltas are emitted as soon as they arrive so the TUI can render
228
+ * partial write previews before the tool executes. End events are still flushed
229
+ * in index order to keep multi-call turns deterministic.
197
230
  */
198
- export async function* translateOpenAIStream(stream) {
231
+ export async function* translateOpenAIStream(stream, options = {}) {
199
232
  const toolCalls = new Map();
233
+ const textFilter = createProviderProtocolArtifactFilter();
234
+ const toolArgsMergeMode = options.toolArgsMergeMode ?? "delta";
235
+ // DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
236
+ // once via a dedicated `reasoning_content` / `thinking` field, and again
237
+ // embedded as `<think>...</think>` inside `delta.content`. Track whether we
238
+ // have seen the dedicated channel; if yes, strip <think> blocks from text
239
+ // silently instead of yielding a second reasoning_delta.
240
+ let hasDedicatedReasoningChannel = false;
200
241
  function* flushToolCalls() {
201
242
  if (toolCalls.size === 0)
202
243
  return;
@@ -204,13 +245,37 @@ export async function* translateOpenAIStream(stream) {
204
245
  for (const [, entry] of sorted) {
205
246
  if (!entry.id || !entry.name)
206
247
  continue;
207
- const args = normalizeToolArgs(entry.args);
208
- yield { type: "tool_call", id: entry.id, name: entry.name, arguments: "", isStart: true, isEnd: false };
209
- yield { type: "tool_call", id: entry.id, name: entry.name, arguments: args, isStart: false, isEnd: false };
210
- yield { type: "tool_call", id: entry.id, name: entry.name, arguments: "", isStart: false, isEnd: true };
248
+ if (!entry.started) {
249
+ yield { type: "tool_call", id: entry.id, name: entry.name, arguments: "", isStart: true, isEnd: false };
250
+ entry.started = true;
251
+ if (entry.args) {
252
+ yield { type: "tool_call", id: entry.id, name: entry.name, arguments: entry.args, isStart: false, isEnd: false };
253
+ }
254
+ }
255
+ const normalized = normalizeToolArgsDetailed(entry.args);
256
+ debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt: normalized.corrupt });
257
+ yield {
258
+ type: "tool_call",
259
+ id: entry.id,
260
+ name: entry.name,
261
+ arguments: "",
262
+ argumentsFull: normalized.args,
263
+ argumentsCorrupt: normalized.corrupt || undefined,
264
+ isStart: false,
265
+ isEnd: true,
266
+ };
211
267
  }
212
268
  toolCalls.clear();
213
269
  }
270
+ function* startToolCallIfReady(entry) {
271
+ if (entry.started || !entry.id || !entry.name)
272
+ return;
273
+ entry.started = true;
274
+ yield { type: "tool_call", id: entry.id, name: entry.name, arguments: "", isStart: true, isEnd: false };
275
+ if (entry.args) {
276
+ yield { type: "tool_call", id: entry.id, name: entry.name, arguments: entry.args, isStart: false, isEnd: false };
277
+ }
278
+ }
214
279
  for await (const chunk of stream) {
215
280
  const delta = chunk.choices?.[0]?.delta;
216
281
  const usage = chunk.usage;
@@ -231,21 +296,26 @@ export async function* translateOpenAIStream(stream) {
231
296
  }
232
297
  const reasoning = delta?.reasoning ?? delta?.thinking ?? delta?.reasoning_content;
233
298
  if (reasoning) {
299
+ hasDedicatedReasoningChannel = true;
234
300
  yield { type: "reasoning_delta", content: reasoning };
235
301
  }
236
302
  if (delta?.content) {
237
303
  const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/);
238
304
  if (thinkMatch) {
239
- if (thinkMatch[1]) {
305
+ if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
240
306
  yield { type: "reasoning_delta", content: thinkMatch[1] };
241
307
  }
242
308
  const remaining = delta.content.replace(/<think>[\s\S]*?<\/think>/, "");
243
- if (remaining) {
244
- yield { type: "text", content: remaining };
309
+ const cleaned = textFilter.push(remaining);
310
+ if (cleaned) {
311
+ yield { type: "text", content: cleaned };
245
312
  }
246
313
  }
247
314
  else {
248
- yield { type: "text", content: delta.content };
315
+ const cleaned = textFilter.push(delta.content);
316
+ if (cleaned) {
317
+ yield { type: "text", content: cleaned };
318
+ }
249
319
  }
250
320
  }
251
321
  if (delta?.tool_calls) {
@@ -253,15 +323,29 @@ export async function* translateOpenAIStream(stream) {
253
323
  const idx = typeof tc.index === "number" ? tc.index : 0;
254
324
  let entry = toolCalls.get(idx);
255
325
  if (!entry) {
256
- entry = { id: "", name: "", args: "" };
326
+ entry = { id: "", name: "", args: "", started: false };
257
327
  toolCalls.set(idx, entry);
258
328
  }
259
329
  if (tc.id)
260
330
  entry.id = tc.id;
261
331
  if (tc.function?.name)
262
332
  entry.name = tc.function.name;
263
- if (typeof tc.function?.arguments === "string")
264
- entry.args += tc.function.arguments;
333
+ yield* startToolCallIfReady(entry);
334
+ if (typeof tc.function?.arguments === "string" && tc.function.arguments) {
335
+ debugToolArgs({ stage: "raw-chunk", id: entry.id, name: entry.name, idx, raw: tc.function.arguments });
336
+ const merged = mergeToolArgumentDelta(entry.args, tc.function.arguments, toolArgsMergeMode);
337
+ entry.args = merged.args;
338
+ if (entry.started && merged.delta) {
339
+ yield {
340
+ type: "tool_call",
341
+ id: entry.id,
342
+ name: entry.name,
343
+ arguments: merged.delta,
344
+ isStart: false,
345
+ isEnd: false,
346
+ };
347
+ }
348
+ }
265
349
  }
266
350
  }
267
351
  const finishReason = chunk.choices?.[0]?.finish_reason;
@@ -269,5 +353,36 @@ export async function* translateOpenAIStream(stream) {
269
353
  yield* flushToolCalls();
270
354
  }
271
355
  }
356
+ const remainingText = textFilter.flush();
357
+ if (remainingText) {
358
+ yield { type: "text", content: remainingText };
359
+ }
272
360
  yield* flushToolCalls();
273
361
  }
362
+ function mergeToolArgumentDelta(current, incoming, mode) {
363
+ if (!current) {
364
+ debugToolArgs({ stage: "merge", branch: "empty-current", current, incoming, args: incoming, delta: incoming });
365
+ return { args: incoming, delta: incoming };
366
+ }
367
+ if (!incoming) {
368
+ debugToolArgs({ stage: "merge", branch: "empty-incoming", current, incoming, args: current, delta: "" });
369
+ return { args: current, delta: "" };
370
+ }
371
+ if (mode === "snapshot") {
372
+ // Snapshot streams repeat the current full argument buffer. Only treat a
373
+ // chunk as duplicate when it is exactly equal, or as growth when it carries
374
+ // the current buffer as a prefix. A suffix match is not enough: the next
375
+ // legitimate delta can be a single trailing character like "0".
376
+ if (incoming === current) {
377
+ debugToolArgs({ stage: "merge", branch: "snapshot-dup", current, incoming, args: current, delta: "" });
378
+ return { args: current, delta: "" };
379
+ }
380
+ if (incoming.startsWith(current)) {
381
+ const delta = incoming.slice(current.length);
382
+ debugToolArgs({ stage: "merge", branch: "snapshot-grow", current, incoming, args: incoming, delta });
383
+ return { args: incoming, delta };
384
+ }
385
+ }
386
+ debugToolArgs({ stage: "merge", branch: mode === "delta" ? "delta-append" : "snapshot-fallback-concat", current, incoming, args: current + incoming, delta: incoming });
387
+ return { args: current + incoming, delta: incoming };
388
+ }
@@ -193,6 +193,8 @@ function normalizeMessageToEntries(message, id, timestamp) {
193
193
  }
194
194
  case "tool":
195
195
  return [{ id, type: "tool_result", message, timestamp }];
196
+ case "meta":
197
+ return [];
196
198
  case "system":
197
199
  return [{
198
200
  id,
@@ -241,7 +243,7 @@ function pruneIncompleteTail(messages) {
241
243
  let sawNonUserInCurrentTurn = false;
242
244
  for (let i = 0; i < messages.length; i++) {
243
245
  const message = messages[i];
244
- if (message.role === "system")
246
+ if (message.role === "system" || message.role === "meta")
245
247
  continue;
246
248
  if (message.role === "user") {
247
249
  currentTurnStart = i;
@@ -30,5 +30,7 @@ export interface SystemPromptOptions {
30
30
  skills?: SkillSummary[];
31
31
  /** Prompt-visible memory guidance and summaries */
32
32
  memoryPrompt?: string;
33
+ /** Durable child-agent profile prompt used for subagents. */
34
+ agentProfilePrompt?: string;
33
35
  }
34
36
  export declare function buildSystemPrompt(options?: SystemPromptOptions): string;
@@ -0,0 +1,6 @@
1
+ import type { ToolRegistryEntry } from "../types.js";
2
+ export declare function createSpawnAgentTool(): ToolRegistryEntry;
3
+ export declare function createWaitAgentTool(): ToolRegistryEntry;
4
+ export declare function createSendInputTool(): ToolRegistryEntry;
5
+ export declare function createCloseAgentTool(): ToolRegistryEntry;
6
+ export declare function createAgentLifecycleTools(): ToolRegistryEntry[];