@bubblebrain-ai/bubble 0.0.56 → 0.0.57
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/README.md +35 -0
- package/dist/agent/child-runner.d.ts +7 -0
- package/dist/agent/child-runner.js +67 -3
- package/dist/agent/subagent/runtime.d.ts +3 -0
- package/dist/agent/subagent/runtime.js +38 -8
- package/dist/agent/transcript-helpers.d.ts +1 -1
- package/dist/agent/transcript-helpers.js +4 -3
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +36 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +22 -3
- package/dist/context/compact.js +8 -2
- package/dist/feishu/agent-host/run-driver.js +3 -0
- package/dist/main.js +16 -1
- package/dist/mcp/client.js +15 -1
- package/dist/mcp/transports.d.ts +2 -2
- package/dist/mcp/types.d.ts +1 -1
- package/dist/memory/phase1.js +4 -0
- package/dist/model-catalog.js +2 -0
- package/dist/provider-error-record.d.ts +34 -0
- package/dist/provider-error-record.js +185 -0
- package/dist/provider-transform.js +1 -0
- package/dist/sdk/index.js +5 -0
- package/dist/session-log.d.ts +3 -1
- package/dist/session-log.js +11 -0
- package/dist/session-types.d.ts +6 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.js +4 -0
- package/dist/stream-json.d.ts +13 -0
- package/dist/stream-json.js +74 -0
- package/dist/tui/controller/agent-event-reducer.js +2 -1
- package/dist/tui/controller/controller.js +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,41 @@ Resume your last conversation:
|
|
|
67
67
|
bubble --resume
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
## Persistent headless sessions
|
|
71
|
+
|
|
72
|
+
To keep one agent session and its MCP connections alive across multiple prompts:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bubble -p --input-format stream-json --output-format stream-json \
|
|
76
|
+
--model zhipuai-coding-plan:glm-5.3 --session my-session
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Write one JSON message per line to stdin (keep stdin open between turns):
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{"type":"user","request_id":"turn-1","message":{"role":"user","content":"Inspect the page."}}
|
|
83
|
+
{"type":"user","request_id":"turn-2","message":{"role":"user","content":"Continue with the next action."}}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Stdout contains only newline-delimited JSON: one `system/init` event with the model
|
|
87
|
+
and `session_id`, `stream_event` messages wrapping native agent events (including
|
|
88
|
+
`text_delta`, `tool_start`, and `tool_end`), and one `result` per input. Results
|
|
89
|
+
include `is_error`, text in `result`, token usage, and the input `request_id` when
|
|
90
|
+
provided. This is Bubble's protocol; it does not claim full Claude Code protocol
|
|
91
|
+
compatibility. Input content may also be an array of text blocks.
|
|
92
|
+
|
|
93
|
+
Turns run sequentially in arrival order and retain the same agent history, MCP
|
|
94
|
+
connections, and tool discovery state. Malformed input and failed turns produce an
|
|
95
|
+
error result; the process remains available for the next input. An optional
|
|
96
|
+
`session_id` must match the initialized session. EOF drains submitted turns, then
|
|
97
|
+
releases resources. SIGINT/SIGTERM stop the process. Input lines are limited to
|
|
98
|
+
1 MiB. Permission settings remain unchanged unless explicitly overridden.
|
|
99
|
+
|
|
100
|
+
Both stream format flags require `-p`; positional prompts are not accepted in this
|
|
101
|
+
mode. To resume a saved session, pass both `--resume` and `--session <name>` so no
|
|
102
|
+
interactive picker is needed. Existing plain and single-result JSON print modes
|
|
103
|
+
retain their behavior.
|
|
104
|
+
|
|
70
105
|
## Embed with the SDK
|
|
71
106
|
|
|
72
107
|
The npm package exports `BubbleSdk` from both `@bubblebrain-ai/bubble` and
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
import type { SubagentRunOutcome } from "./subagent-scheduler.js";
|
|
12
12
|
import type { SubagentFinalReason, SubagentThreadRecord } from "./subagent-control.js";
|
|
13
13
|
import type { AgentEvent, Message, ToolRegistryEntry, ToolResult, ToolUpdate } from "../types.js";
|
|
14
|
+
/** Bubble-authored configuration failure whose message contains no provider data. */
|
|
15
|
+
export declare class SafeSubagentConfigurationError extends Error {
|
|
16
|
+
}
|
|
14
17
|
export interface ChildRunOptions {
|
|
15
18
|
approval: "fail" | "disabled";
|
|
16
19
|
abortSignal?: AbortSignal;
|
|
@@ -27,6 +30,10 @@ export interface ChildRunnerHost {
|
|
|
27
30
|
runLifecycleHook(record: SubagentThreadRecord, cwd: string, eventName: "SubagentStart" | "SubagentStop", status?: string, error?: string, abortSignal?: AbortSignal): Promise<void>;
|
|
28
31
|
finalizeBlocked(record: SubagentThreadRecord, error: string, options: ChildRunOptions): void;
|
|
29
32
|
createInstance(record: SubagentThreadRecord, tools: ToolRegistryEntry[], cwd: string, forkContext?: boolean): Promise<NonNullable<SubagentThreadRecord["agent"]>>;
|
|
33
|
+
recordProviderError(record: SubagentThreadRecord, error: unknown, request: {
|
|
34
|
+
messageCount: number;
|
|
35
|
+
toolCount: number;
|
|
36
|
+
}): void;
|
|
30
37
|
notifyWaiters(record: SubagentThreadRecord): void;
|
|
31
38
|
/** Called on every final state so background results can be ingested (§5). */
|
|
32
39
|
onFinal(record: SubagentThreadRecord, options: ChildRunOptions): void;
|
|
@@ -13,8 +13,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";
|
|
16
|
+
import { sanitizeProviderErrorText } from "../provider-error-record.js";
|
|
16
17
|
import { mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./profiles.js";
|
|
17
18
|
import { estimateHandoffTokens, HANDOFF_TOKEN_FLOOR, isIntermediateHandoff, stripInternalTagFragments, } from "./subagent-summary.js";
|
|
19
|
+
/** Bubble-authored configuration failure whose message contains no provider data. */
|
|
20
|
+
export class SafeSubagentConfigurationError extends Error {
|
|
21
|
+
}
|
|
18
22
|
export class ChildRunner {
|
|
19
23
|
host;
|
|
20
24
|
constructor(host) {
|
|
@@ -58,7 +62,13 @@ export class ChildRunner {
|
|
|
58
62
|
catch (error) {
|
|
59
63
|
// Instance creation failed before the run started: no SubagentStart
|
|
60
64
|
// fired, so no SubagentStop follows (§9 — hooks pair per started run).
|
|
61
|
-
|
|
65
|
+
const isSafeConfigurationError = error instanceof SafeSubagentConfigurationError;
|
|
66
|
+
if (!isSafeConfigurationError) {
|
|
67
|
+
this.host.recordProviderError(record, error, { messageCount: 0, toolCount: tools.length });
|
|
68
|
+
}
|
|
69
|
+
this.host.finalizeBlocked(record, isSafeConfigurationError
|
|
70
|
+
? error.message
|
|
71
|
+
: sanitizeProviderErrorText(error?.message || String(error)), options);
|
|
62
72
|
record.finalReason = "failed_fatal";
|
|
63
73
|
this.host.onFinal(record, options);
|
|
64
74
|
return { kind: "final" };
|
|
@@ -168,7 +178,15 @@ export class ChildRunner {
|
|
|
168
178
|
? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal)
|
|
169
179
|
: "failed_transient";
|
|
170
180
|
record.summary = sanitizeSubagentSummary(record.summary);
|
|
171
|
-
|
|
181
|
+
if (!cancelled) {
|
|
182
|
+
this.host.recordProviderError(record, error, {
|
|
183
|
+
messageCount: messagesBeforeInterruptedBoundary(subAgent.messages),
|
|
184
|
+
toolCount: tools.length,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
record.error = cancelled
|
|
188
|
+
? "Subagent was cancelled."
|
|
189
|
+
: sanitizeProviderErrorText(error?.message || String(error));
|
|
172
190
|
record.updatedAt = Date.now();
|
|
173
191
|
await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, record.error, options.abortSignal);
|
|
174
192
|
emit(record.status, undefined, record.error);
|
|
@@ -178,7 +196,49 @@ export class ChildRunner {
|
|
|
178
196
|
}
|
|
179
197
|
record.summary = sanitizeSubagentSummary(record.summary);
|
|
180
198
|
if (needsExplicitFinalSummary(record, executedAnyTool)) {
|
|
181
|
-
|
|
199
|
+
try {
|
|
200
|
+
await this.runFinalSummaryTurn(record, subAgent, runCwd, options.abortSignal, emit);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const abortedNow = record.abortController.signal.aborted
|
|
204
|
+
|| options.abortSignal?.aborted
|
|
205
|
+
|| error instanceof AgentAbortError
|
|
206
|
+
|| error?.name === "AbortError";
|
|
207
|
+
if (isRateLimitError(error) && !abortedNow) {
|
|
208
|
+
record.status = "queued";
|
|
209
|
+
record.updatedAt = Date.now();
|
|
210
|
+
stripTrailingModelInterruptedBoundary(subAgent.messages);
|
|
211
|
+
emit("queued", undefined, `Rate limited; ${record.nickname} will retry with its context intact.`);
|
|
212
|
+
return { kind: "rate_limited", retryAfterMs: error.retryAfterMs };
|
|
213
|
+
}
|
|
214
|
+
if (isProviderTransportError(error) && !abortedNow) {
|
|
215
|
+
record.status = "queued";
|
|
216
|
+
record.updatedAt = Date.now();
|
|
217
|
+
stripTrailingModelInterruptedBoundary(subAgent.messages);
|
|
218
|
+
emit("queued", undefined, `Connection error; ${record.nickname} will retry with its context intact.`);
|
|
219
|
+
return { kind: "transport_retry" };
|
|
220
|
+
}
|
|
221
|
+
const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
|
|
222
|
+
record.status = cancelled ? "cancelled" : "failed";
|
|
223
|
+
record.finalReason = cancelled
|
|
224
|
+
? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal)
|
|
225
|
+
: "failed_transient";
|
|
226
|
+
if (!cancelled) {
|
|
227
|
+
this.host.recordProviderError(record, error, {
|
|
228
|
+
messageCount: messagesBeforeInterruptedBoundary(subAgent.messages),
|
|
229
|
+
toolCount: tools.length,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
record.error = cancelled
|
|
233
|
+
? "Subagent was cancelled."
|
|
234
|
+
: sanitizeProviderErrorText(error?.message || String(error));
|
|
235
|
+
record.updatedAt = Date.now();
|
|
236
|
+
await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, record.error, options.abortSignal);
|
|
237
|
+
emit(record.status, undefined, record.error);
|
|
238
|
+
this.host.notifyWaiters(record);
|
|
239
|
+
this.host.onFinal(record, options);
|
|
240
|
+
return { kind: "final" };
|
|
241
|
+
}
|
|
182
242
|
}
|
|
183
243
|
record.status = "completed";
|
|
184
244
|
record.finalReason = "completed";
|
|
@@ -289,6 +349,10 @@ export function stripTrailingModelInterruptedBoundary(messages) {
|
|
|
289
349
|
break;
|
|
290
350
|
}
|
|
291
351
|
}
|
|
352
|
+
function messagesBeforeInterruptedBoundary(messages) {
|
|
353
|
+
const last = messages.at(-1);
|
|
354
|
+
return Math.max(0, messages.length - (last?.role === "assistant" && last.content.startsWith("[model request interrupted") ? 1 : 0));
|
|
355
|
+
}
|
|
292
356
|
export function summarizeSubagentToolEnd(event) {
|
|
293
357
|
const metadata = (event.result.metadata ?? {});
|
|
294
358
|
const reason = readString(metadata.reason);
|
|
@@ -4,6 +4,7 @@ import { SubagentStore } from "../subagent-store.js";
|
|
|
4
4
|
import { type AgentCategoriesConfig, type ResolvedSubagentRoute } from "../categories.js";
|
|
5
5
|
import type { SubagentRouter } from "./router.js";
|
|
6
6
|
import type { AgentEvent, ContentPart, Message, PermissionMode, Provider, ThinkingLevel, ToolRegistryEntry, ToolUpdate } from "../../types.js";
|
|
7
|
+
import type { ProviderErrorContext } from "../../provider-error-record.js";
|
|
7
8
|
import type { AgentSubagentRuntimeConfig } from "../../agent.js";
|
|
8
9
|
import type { HookCombinedResult } from "../../hooks/index.js";
|
|
9
10
|
/** The child agent surface the runtime drives; structurally satisfied by Agent. */
|
|
@@ -44,6 +45,7 @@ export interface SubagentRuntimeParent {
|
|
|
44
45
|
*/
|
|
45
46
|
allTools(): ToolRegistryEntry[];
|
|
46
47
|
createChild(spec: ChildAgentSpec): ChildAgentLike;
|
|
48
|
+
recordProviderError(error: unknown, context: ProviderErrorContext): void;
|
|
47
49
|
runExternalHook(input: {
|
|
48
50
|
eventName: string;
|
|
49
51
|
cwd: string;
|
|
@@ -185,6 +187,7 @@ export declare class SubagentRuntime {
|
|
|
185
187
|
private maybeEnqueueIngestion;
|
|
186
188
|
private finalizeSubagentBlocked;
|
|
187
189
|
private dispatchSubagentRun;
|
|
190
|
+
private recordSubagentProviderError;
|
|
188
191
|
private emitSubagentLifecycle;
|
|
189
192
|
/** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
|
|
190
193
|
private wakeSubagentUpdateWaiters;
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* `SubagentRuntimeParent` as a LIVE accessor, never a construction-time
|
|
12
12
|
* snapshot: `/model` reassigns the parent's provider/model/thinking
|
|
13
13
|
* mid-session and children must inherit the current values.
|
|
14
|
-
* -
|
|
15
|
-
* notices are PULLED (`drainToolUpdates` /
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - Conversation state is never pushed back into the parent. Tool updates
|
|
15
|
+
* and ingestion notices are PULLED (`drainToolUpdates` /
|
|
16
|
+
* `drainIngestionNotices`). The only direct sink is a terminal,
|
|
17
|
+
* fail-closed provider diagnostic that contains no child transcript.
|
|
18
18
|
*
|
|
19
19
|
* `new Agent(...)` stays on Agent, reached through `parent.createChild`. That
|
|
20
20
|
* is the only genuine call back into the parent, and keeping it there is what
|
|
@@ -31,7 +31,7 @@ import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateA
|
|
|
31
31
|
import { snapshotSubagentThread, subagentResultFromThread } from "../subagent-control.js";
|
|
32
32
|
import { SubagentStore } from "../subagent-store.js";
|
|
33
33
|
import { SubagentScheduler } from "../subagent-scheduler.js";
|
|
34
|
-
import { ChildRunner, classifySubagentAbortReason } from "../child-runner.js";
|
|
34
|
+
import { ChildRunner, classifySubagentAbortReason, SafeSubagentConfigurationError, } from "../child-runner.js";
|
|
35
35
|
import { ResultIntegrator } from "../result-integrator.js";
|
|
36
36
|
import { SubagentAbortError } from "../abort-errors.js";
|
|
37
37
|
import { createSubagentWorktree, finalizeSubagentWorktree } from "../worktree.js";
|
|
@@ -75,6 +75,7 @@ export class SubagentRuntime {
|
|
|
75
75
|
runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
|
|
76
76
|
finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
|
|
77
77
|
createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
|
|
78
|
+
recordProviderError: (record, error, request) => this.recordSubagentProviderError(record, error, request),
|
|
78
79
|
notifyWaiters: (record) => this.store.notifyWaiters(record),
|
|
79
80
|
onFinal: (record, options) => {
|
|
80
81
|
this.reclaimWorktree(record);
|
|
@@ -506,7 +507,7 @@ export class SubagentRuntime {
|
|
|
506
507
|
onCancelledWhileQueued: (reason) => {
|
|
507
508
|
record.status = "cancelled";
|
|
508
509
|
record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
|
|
509
|
-
record.error =
|
|
510
|
+
record.error = "Subagent was cancelled.";
|
|
510
511
|
record.updatedAt = Date.now();
|
|
511
512
|
// This path is NOT only "run never started" — a 429/transport
|
|
512
513
|
// failure re-queues the entry with the abort listener re-armed, so
|
|
@@ -527,6 +528,7 @@ export class SubagentRuntime {
|
|
|
527
528
|
record.status = "failed";
|
|
528
529
|
record.finalReason = "rate_limited_exhausted";
|
|
529
530
|
record.error = `Provider rate limit persisted after ${attempts} attempts.`;
|
|
531
|
+
this.recordSubagentProviderError(record, Object.assign(new Error(record.error), { status: 429, code: "rate_limit_exhausted" }), { messageCount: record.agent?.messages.length ?? 0, toolCount: 0 });
|
|
530
532
|
record.updatedAt = Date.now();
|
|
531
533
|
// Reclaim before persist so the handoff note lands in the
|
|
532
534
|
// persisted toolNotes.
|
|
@@ -543,6 +545,10 @@ export class SubagentRuntime {
|
|
|
543
545
|
// to recover the child with its context intact.
|
|
544
546
|
record.finalReason = "failed_transient";
|
|
545
547
|
record.error = `Provider transport error persisted after ${attempts} attempts.`;
|
|
548
|
+
this.recordSubagentProviderError(record, new Error(record.error), {
|
|
549
|
+
messageCount: record.agent?.messages.length ?? 0,
|
|
550
|
+
toolCount: 0,
|
|
551
|
+
});
|
|
546
552
|
record.updatedAt = Date.now();
|
|
547
553
|
this.reclaimWorktree(record);
|
|
548
554
|
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
@@ -553,6 +559,22 @@ export class SubagentRuntime {
|
|
|
553
559
|
},
|
|
554
560
|
});
|
|
555
561
|
}
|
|
562
|
+
recordSubagentProviderError(record, error, request) {
|
|
563
|
+
const route = record.route ?? {
|
|
564
|
+
providerId: this.parent.providerId,
|
|
565
|
+
model: this.parent.apiModel,
|
|
566
|
+
thinkingLevel: this.parent.thinkingLevel,
|
|
567
|
+
inherited: true,
|
|
568
|
+
};
|
|
569
|
+
this.parent.recordProviderError(error, {
|
|
570
|
+
providerId: route.providerId,
|
|
571
|
+
modelId: route.model,
|
|
572
|
+
model: route.providerId ? `${route.providerId}:${route.model}` : route.model,
|
|
573
|
+
thinkingLevel: route.thinkingLevel,
|
|
574
|
+
messageCount: request.messageCount,
|
|
575
|
+
toolCount: request.toolCount,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
556
578
|
emitSubagentLifecycle(record, options, status, event, message) {
|
|
557
579
|
const update = this.buildSubagentUpdate(record, status, event, message);
|
|
558
580
|
options.directEmit?.(update);
|
|
@@ -633,7 +655,15 @@ export class SubagentRuntime {
|
|
|
633
655
|
// NOTE: record.worktree must be assigned before anything below can
|
|
634
656
|
// throw — ChildRunner's failure path reclaims it off the record.
|
|
635
657
|
if (!record.worktree) {
|
|
636
|
-
|
|
658
|
+
try {
|
|
659
|
+
record.worktree = createSubagentWorktree(cwd, record.agentId);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
const message = error instanceof Error && error.message.includes("need a git repository")
|
|
663
|
+
? "write_worktree subagents need a git repository: the working directory is not inside one."
|
|
664
|
+
: "Failed to create subagent worktree.";
|
|
665
|
+
throw new SafeSubagentConfigurationError(message);
|
|
666
|
+
}
|
|
637
667
|
}
|
|
638
668
|
childCwd = record.worktree.path;
|
|
639
669
|
childMode = "default";
|
|
@@ -705,7 +735,7 @@ export class SubagentRuntime {
|
|
|
705
735
|
return this.parent.provider;
|
|
706
736
|
}
|
|
707
737
|
if (!this.parent.providerFactory) {
|
|
708
|
-
throw new
|
|
738
|
+
throw new SafeSubagentConfigurationError([
|
|
709
739
|
`Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
|
|
710
740
|
`but the parent agent only has provider "${this.parent.providerId || "none"}" and no provider factory is configured.`,
|
|
711
741
|
].join(" "));
|
|
@@ -8,7 +8,7 @@ export declare function findMissingRequiredArgs(schema: {
|
|
|
8
8
|
required?: string[];
|
|
9
9
|
} | undefined, args: Record<string, any> | undefined): string[];
|
|
10
10
|
export declare function shouldAppendModelInterruptedBoundary(messages: Message[]): boolean;
|
|
11
|
-
export declare function createModelInterruptedMessage(
|
|
11
|
+
export declare function createModelInterruptedMessage(_error: unknown, metadata: {
|
|
12
12
|
model: string;
|
|
13
13
|
providerId: string;
|
|
14
14
|
modelId: string;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { summarizeInterruptError } from "./abort-errors.js";
|
|
2
1
|
export function findMissingRequiredArgs(schema, args) {
|
|
3
2
|
const required = schema?.required;
|
|
4
3
|
if (!required || required.length === 0)
|
|
@@ -19,10 +18,12 @@ export function findMissingRequiredArgs(schema, args) {
|
|
|
19
18
|
export function shouldAppendModelInterruptedBoundary(messages) {
|
|
20
19
|
return messages.at(-1)?.role === "tool";
|
|
21
20
|
}
|
|
22
|
-
export function createModelInterruptedMessage(
|
|
21
|
+
export function createModelInterruptedMessage(_error, metadata) {
|
|
23
22
|
return {
|
|
24
23
|
role: "assistant",
|
|
25
|
-
|
|
24
|
+
// This boundary is replayed to future providers and memory extraction.
|
|
25
|
+
// Never copy provider error text into conversational history.
|
|
26
|
+
content: "[model request interrupted before a final answer was produced]",
|
|
26
27
|
model: metadata.model,
|
|
27
28
|
providerId: metadata.providerId,
|
|
28
29
|
modelId: metadata.modelId,
|
package/dist/agent.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { createUpdateQueue } from "./agent/update-queue.js";
|
|
|
18
18
|
import { type RateLimitPolicy } from "./network/errors.js";
|
|
19
19
|
import type { SkillSummary } from "./skills/types.js";
|
|
20
20
|
import type { FileStateTracker } from "./tools/file-state.js";
|
|
21
|
+
import { type SanitizedProviderError } from "./provider-error-record.js";
|
|
21
22
|
export { AgentAbortError, SubagentAbortError } from "./agent/abort-errors.js";
|
|
22
23
|
export declare const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
|
|
23
24
|
/** Runtime tuning for the subagent scheduler. */
|
|
@@ -51,6 +52,8 @@ export interface AgentOptions {
|
|
|
51
52
|
};
|
|
52
53
|
systemPrompt?: string;
|
|
53
54
|
onMessageAppend?: (message: Message) => void;
|
|
55
|
+
/** Persistable, allowlisted diagnostics for terminal provider failures. */
|
|
56
|
+
onProviderError?: (error: SanitizedProviderError) => void;
|
|
54
57
|
onToolResult?: (toolName: string, result: ToolResult) => void;
|
|
55
58
|
onModeUpdate?: (mode: PermissionMode) => void;
|
|
56
59
|
/**
|
|
@@ -157,6 +160,7 @@ export declare class Agent {
|
|
|
157
160
|
private onModeUpdate?;
|
|
158
161
|
private onCompactionApplied?;
|
|
159
162
|
private onMessageAppend?;
|
|
163
|
+
private onProviderError?;
|
|
160
164
|
private onToolResult?;
|
|
161
165
|
private hookDefinitions;
|
|
162
166
|
private externalHooks?;
|
|
@@ -440,6 +444,8 @@ export declare class Agent {
|
|
|
440
444
|
*/
|
|
441
445
|
private persistCompactionSummary;
|
|
442
446
|
private appendMessage;
|
|
447
|
+
private persistProviderError;
|
|
448
|
+
private recordProviderError;
|
|
443
449
|
private appendInterruptedAssistantBoundary;
|
|
444
450
|
private executeTool;
|
|
445
451
|
}
|
package/dist/agent.js
CHANGED
|
@@ -32,6 +32,7 @@ import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMeta
|
|
|
32
32
|
import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
|
|
33
33
|
import { buildToolPromptOptions } from "./tools/prompt-metadata.js";
|
|
34
34
|
import { stopAutoServersForSession } from "./tools/server-manager.js";
|
|
35
|
+
import { createSanitizedProviderError, } from "./provider-error-record.js";
|
|
35
36
|
import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceMessage, summarizeTraceToolResult, summarizeTraceValue, traceEvent, } from "./debug-trace.js";
|
|
36
37
|
const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 2;
|
|
37
38
|
const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
|
|
@@ -127,6 +128,7 @@ export class Agent {
|
|
|
127
128
|
onModeUpdate;
|
|
128
129
|
onCompactionApplied;
|
|
129
130
|
onMessageAppend;
|
|
131
|
+
onProviderError;
|
|
130
132
|
onToolResult;
|
|
131
133
|
hookDefinitions;
|
|
132
134
|
externalHooks;
|
|
@@ -174,6 +176,7 @@ export class Agent {
|
|
|
174
176
|
this.thinkingLevel = options.thinkingLevel ?? "off";
|
|
175
177
|
this._mode = options.mode ?? "default";
|
|
176
178
|
this.onMessageAppend = options.onMessageAppend;
|
|
179
|
+
this.onProviderError = options.onProviderError;
|
|
177
180
|
this.onToolResult = options.onToolResult;
|
|
178
181
|
this.onModeUpdate = options.onModeUpdate;
|
|
179
182
|
this.onCompactionApplied = options.onCompactionApplied;
|
|
@@ -228,6 +231,7 @@ export class Agent {
|
|
|
228
231
|
// profile is the author pre-unlocking them).
|
|
229
232
|
allTools: () => [...this.tools.values()],
|
|
230
233
|
createChild: (spec) => this.createChildAgent(spec),
|
|
234
|
+
recordProviderError: (error, context) => this.recordProviderError(error, context),
|
|
231
235
|
runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
|
|
232
236
|
},
|
|
233
237
|
router: this.router,
|
|
@@ -826,6 +830,11 @@ export class Agent {
|
|
|
826
830
|
parameters: t.parameters,
|
|
827
831
|
}));
|
|
828
832
|
const toolDefinitionTokens = estimateToolDefinitionsTokens(toolDefinitions, this.providerId);
|
|
833
|
+
let currentRequestMessageCount = this.messages.length;
|
|
834
|
+
const persistTerminalProviderError = (error) => this.persistProviderError(error, {
|
|
835
|
+
messageCount: currentRequestMessageCount,
|
|
836
|
+
toolCount: toolDefinitions.length,
|
|
837
|
+
});
|
|
829
838
|
// LLM-driven compaction runs ahead of projector's algorithmic passes. If
|
|
830
839
|
// it succeeds, this.messages is replaced with [preserved system+meta] +
|
|
831
840
|
// [LLM summary] + [last user msg], and the projector becomes a no-op for
|
|
@@ -845,6 +854,7 @@ export class Agent {
|
|
|
845
854
|
// A fresh estimate also lets us account for current tool schemas.
|
|
846
855
|
additionalInputTokens: toolDefinitionTokens,
|
|
847
856
|
});
|
|
857
|
+
currentRequestMessageCount = projectedMessages.length;
|
|
848
858
|
const requestBudget = getContextBudget(this.providerId, this.apiModel, projectedMessages, { additionalInputTokens: toolDefinitionTokens });
|
|
849
859
|
currentRequestEstimate = requestBudget.estimatedTokens;
|
|
850
860
|
const maxInputTokens = getMaxInputTokens(requestBudget.contextWindow);
|
|
@@ -1043,6 +1053,8 @@ export class Agent {
|
|
|
1043
1053
|
error: summarizeTraceError(error),
|
|
1044
1054
|
}, traceContext);
|
|
1045
1055
|
if (assistantAppended) {
|
|
1056
|
+
if (!isAbortLikeError(error, abortSignal))
|
|
1057
|
+
persistTerminalProviderError(error);
|
|
1046
1058
|
throw error;
|
|
1047
1059
|
}
|
|
1048
1060
|
const streamInterruption = isProviderStreamInterruption(error) ? error : undefined;
|
|
@@ -1077,9 +1089,12 @@ export class Agent {
|
|
|
1077
1089
|
}));
|
|
1078
1090
|
assistantAppended = true;
|
|
1079
1091
|
}
|
|
1092
|
+
if (!isAbortLikeError(error, abortSignal))
|
|
1093
|
+
persistTerminalProviderError(error);
|
|
1080
1094
|
throw error;
|
|
1081
1095
|
}
|
|
1082
1096
|
if (consecutiveOverflowRecoveries >= MAX_CONSECUTIVE_OVERFLOW_RECOVERIES) {
|
|
1097
|
+
persistTerminalProviderError(error);
|
|
1083
1098
|
throw error;
|
|
1084
1099
|
}
|
|
1085
1100
|
const messagesBeforeRecovery = this.messages;
|
|
@@ -1108,6 +1123,7 @@ export class Agent {
|
|
|
1108
1123
|
this.messages = messagesBeforeRecovery;
|
|
1109
1124
|
this.lastInputTokens = inputTokensBeforeRecovery;
|
|
1110
1125
|
this.lastAnchorMessageCount = anchorBeforeRecovery;
|
|
1126
|
+
persistTerminalProviderError(error);
|
|
1111
1127
|
throw error;
|
|
1112
1128
|
}
|
|
1113
1129
|
consecutiveOverflowRecoveries += 1;
|
|
@@ -1983,6 +1999,26 @@ export class Agent {
|
|
|
1983
1999
|
this.onMessageAppend?.(message);
|
|
1984
2000
|
this.notifyContextChanged();
|
|
1985
2001
|
}
|
|
2002
|
+
persistProviderError(error, request) {
|
|
2003
|
+
this.recordProviderError(error, {
|
|
2004
|
+
providerId: this.providerId,
|
|
2005
|
+
modelId: this.apiModel,
|
|
2006
|
+
model: this._model,
|
|
2007
|
+
thinkingLevel: this.thinkingLevel,
|
|
2008
|
+
messageCount: request.messageCount,
|
|
2009
|
+
toolCount: request.toolCount,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
recordProviderError(error, context) {
|
|
2013
|
+
if (!this.onProviderError)
|
|
2014
|
+
return;
|
|
2015
|
+
try {
|
|
2016
|
+
this.onProviderError(createSanitizedProviderError(error, context));
|
|
2017
|
+
}
|
|
2018
|
+
catch {
|
|
2019
|
+
// Diagnostics are best-effort and must never replace the provider error.
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
1986
2022
|
appendInterruptedAssistantBoundary(currentAssistant, currentAssistantAppended) {
|
|
1987
2023
|
const last = lastProviderMessage(this.messages);
|
|
1988
2024
|
if (last?.role === "assistant" && last.error?.aborted) {
|
package/dist/cli.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface CliArgs {
|
|
|
12
12
|
sessionName?: string;
|
|
13
13
|
print?: boolean;
|
|
14
14
|
/** Print-mode output: plain (default) or a single JSON object on stdout. */
|
|
15
|
-
outputFormat?: "plain" | "json";
|
|
15
|
+
outputFormat?: "plain" | "json" | "stream-json";
|
|
16
|
+
inputFormat?: "text" | "stream-json";
|
|
16
17
|
prompt?: string;
|
|
17
18
|
thinkingLevel?: ThinkingLevel;
|
|
18
19
|
mode?: PermissionMode;
|
package/dist/cli.js
CHANGED
|
@@ -57,13 +57,21 @@ export function parseArgs(argv) {
|
|
|
57
57
|
break;
|
|
58
58
|
case "--output-format": {
|
|
59
59
|
const value = argv[++i];
|
|
60
|
-
if (value !== "plain" && value !== "json") {
|
|
61
|
-
console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain or json.`);
|
|
60
|
+
if (value !== "plain" && value !== "json" && value !== "stream-json") {
|
|
61
|
+
console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain, json, or stream-json.`);
|
|
62
62
|
process.exit(1);
|
|
63
63
|
}
|
|
64
64
|
args.outputFormat = value;
|
|
65
65
|
break;
|
|
66
66
|
}
|
|
67
|
+
case "--input-format": {
|
|
68
|
+
const value = argv[++i];
|
|
69
|
+
if (value !== "text" && value !== "stream-json") {
|
|
70
|
+
throw new Error("--input-format expects text or stream-json");
|
|
71
|
+
}
|
|
72
|
+
args.inputFormat = value;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
67
75
|
case "--plan":
|
|
68
76
|
args.mode = "plan";
|
|
69
77
|
break;
|
|
@@ -96,6 +104,16 @@ export function parseArgs(argv) {
|
|
|
96
104
|
break;
|
|
97
105
|
}
|
|
98
106
|
}
|
|
107
|
+
const streamingInput = args.inputFormat === "stream-json";
|
|
108
|
+
const streamingOutput = args.outputFormat === "stream-json";
|
|
109
|
+
if (streamingInput || streamingOutput) {
|
|
110
|
+
if (!streamingInput || !streamingOutput || !args.print || args.prompt || args.command !== "default") {
|
|
111
|
+
throw new Error("Persistent mode requires -p --input-format stream-json --output-format stream-json and prompts on stdin");
|
|
112
|
+
}
|
|
113
|
+
if (args.resume && !args.sessionName) {
|
|
114
|
+
throw new Error("Persistent mode requires --session when using --resume (no interactive picker)");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
99
117
|
return args;
|
|
100
118
|
}
|
|
101
119
|
export function printHelp() {
|
|
@@ -117,8 +135,9 @@ Options (default):
|
|
|
117
135
|
--dangerously-skip-permissions
|
|
118
136
|
Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
|
|
119
137
|
-p, --print Non-interactive mode (single prompt)
|
|
120
|
-
--output-format <fmt> Print-mode output: plain (default) or json
|
|
138
|
+
--output-format <fmt> Print-mode output: plain (default), json, or stream-json
|
|
121
139
|
(one JSON object on stdout: text, usage, num_turns)
|
|
140
|
+
--input-format <fmt> text (default) or stream-json; use both stream-json flags with -p\n for a persistent session (one user JSON message per line)
|
|
122
141
|
-v, --version Print the installed version and exit
|
|
123
142
|
-h, --help Show this help
|
|
124
143
|
|
package/dist/context/compact.js
CHANGED
|
@@ -118,8 +118,14 @@ export function buildCompactionSummaryMessage(summary) {
|
|
|
118
118
|
}
|
|
119
119
|
export function planSessionCompaction(entries, options = {}) {
|
|
120
120
|
const keepRecentTurns = options.keepRecentTurns ?? 2;
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Provider errors are diagnostics, not model context. Keep the latest audit
|
|
122
|
+
// records across compaction without allowing repeated failures to grow the
|
|
123
|
+
// compacted session forever.
|
|
124
|
+
const metadataEntries = [
|
|
125
|
+
...entries.filter((entry) => entry.type === "metadata"),
|
|
126
|
+
...entries.filter((entry) => entry.type === "provider_error").slice(-20),
|
|
127
|
+
];
|
|
128
|
+
const nonMetadataEntries = entries.filter((entry) => entry.type !== "metadata" && entry.type !== "provider_error");
|
|
123
129
|
const latestSummaryIndex = findLatestSummaryIndex(nonMetadataEntries);
|
|
124
130
|
const baseIndex = latestSummaryIndex >= 0 ? latestSummaryIndex + 1 : 0;
|
|
125
131
|
const activeEntries = nonMetadataEntries.slice(baseIndex);
|
|
@@ -130,6 +130,9 @@ export class RunDriver {
|
|
|
130
130
|
recordMemoryCitations(session.cwd, message.content);
|
|
131
131
|
}
|
|
132
132
|
},
|
|
133
|
+
onProviderError: (error) => {
|
|
134
|
+
session.manager.appendProviderError(error);
|
|
135
|
+
},
|
|
133
136
|
onCompactionApplied: (summary) => {
|
|
134
137
|
session.manager.applyLLMCompaction(summary);
|
|
135
138
|
},
|
package/dist/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { SkillRegistry } from "./skills/registry.js";
|
|
|
20
20
|
import { buildToolPromptOptions, createAllTools } from "./tools/index.js";
|
|
21
21
|
import { getProcessManager } from "./tasks/manager.js";
|
|
22
22
|
import { PromotionChannel } from "./tasks/promotion.js";
|
|
23
|
+
import { runStreamJsonSession } from "./stream-json.js";
|
|
23
24
|
import { PrintRunCollector, formatPrintJson, formatPrintJsonError } from "./print-output.js";
|
|
24
25
|
import { FileStateTracker } from "./tools/file-state.js";
|
|
25
26
|
import { GoalStore } from "./goal/store.js";
|
|
@@ -418,6 +419,9 @@ async function main() {
|
|
|
418
419
|
recordMemoryCitations(args.cwd, message.content);
|
|
419
420
|
}
|
|
420
421
|
},
|
|
422
|
+
onProviderError: (error) => {
|
|
423
|
+
sessionManager?.appendProviderError(error);
|
|
424
|
+
},
|
|
421
425
|
// Auto-compaction summaries are meta messages (dropped above); persist the
|
|
422
426
|
// compacted state as a session summary entry so it survives resume.
|
|
423
427
|
onCompactionApplied: (summary) => {
|
|
@@ -556,7 +560,7 @@ async function main() {
|
|
|
556
560
|
}
|
|
557
561
|
agent.injectDeferredToolsReminder();
|
|
558
562
|
// JSON print mode reserves stdout for the single result object.
|
|
559
|
-
(args.outputFormat === "json" ? console.error : console.log)(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
|
|
563
|
+
(args.outputFormat === "json" || args.outputFormat === "stream-json" ? console.error : console.log)(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
|
|
560
564
|
}
|
|
561
565
|
}
|
|
562
566
|
try {
|
|
@@ -567,6 +571,17 @@ async function main() {
|
|
|
567
571
|
process.exitCode = 1;
|
|
568
572
|
return;
|
|
569
573
|
}
|
|
574
|
+
if (args.inputFormat === "stream-json") {
|
|
575
|
+
await runStreamJsonSession({
|
|
576
|
+
input: process.stdin,
|
|
577
|
+
write: (message) => { process.stdout.write(JSON.stringify(message) + "\n"); },
|
|
578
|
+
sessionId: basename(sessionManager.getSessionFile()),
|
|
579
|
+
model: agent.model,
|
|
580
|
+
run: (prompt) => agent.run(prompt, args.cwd),
|
|
581
|
+
onEvent: (event) => traceEvent("print_agent_event", summarizeAgentEventForTrace(event)),
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
570
585
|
const prompt = args.prompt || (await readPipedStdin()) || "";
|
|
571
586
|
if (!prompt) {
|
|
572
587
|
console.error(chalk.red("Error: No prompt provided."));
|
package/dist/mcp/client.js
CHANGED
|
@@ -154,7 +154,21 @@ export class MCPClient {
|
|
|
154
154
|
}
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
157
|
-
//
|
|
157
|
+
// Ping is a core MCP request and requires no advertised capability. Long-lived
|
|
158
|
+
// servers (including Playwright) close healthy sessions when it is ignored.
|
|
159
|
+
if ("id" in msg && "method" in msg) {
|
|
160
|
+
const response = msg.method === "ping"
|
|
161
|
+
? { jsonrpc: "2.0", id: msg.id, result: {} }
|
|
162
|
+
: { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `Unsupported client method: ${msg.method}` } };
|
|
163
|
+
void this.transport.send(response).catch((error) => {
|
|
164
|
+
for (const pending of this.pending.values()) {
|
|
165
|
+
clearTimeout(pending.timer);
|
|
166
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
167
|
+
}
|
|
168
|
+
this.pending.clear();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Notifications need no response; sampling and roots are not advertised.
|
|
158
172
|
}
|
|
159
173
|
async close() {
|
|
160
174
|
this.closed = true;
|
package/dist/mcp/transports.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare class StdioTransport implements McpTransport {
|
|
|
27
27
|
constructor(config: StdioServerConfig);
|
|
28
28
|
start(): Promise<void>;
|
|
29
29
|
private onStdout;
|
|
30
|
-
send(message: JsonRpcRequest | JsonRpcNotification): Promise<void>;
|
|
30
|
+
send(message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): Promise<void>;
|
|
31
31
|
onMessage(handler: IncomingHandler): void;
|
|
32
32
|
onError(handler: (err: Error) => void): void;
|
|
33
33
|
onClose(handler: () => void): void;
|
|
@@ -44,7 +44,7 @@ export declare class HttpTransport implements McpTransport {
|
|
|
44
44
|
private readonly baseHeaders;
|
|
45
45
|
constructor(config: HttpServerConfig | SseServerConfig);
|
|
46
46
|
start(): Promise<void>;
|
|
47
|
-
send(message: JsonRpcRequest | JsonRpcNotification): Promise<void>;
|
|
47
|
+
send(message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): Promise<void>;
|
|
48
48
|
private parseSseBody;
|
|
49
49
|
onMessage(handler: IncomingHandler): void;
|
|
50
50
|
onError(handler: (err: Error) => void): void;
|
package/dist/mcp/types.d.ts
CHANGED
|
@@ -100,7 +100,7 @@ export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcRespo
|
|
|
100
100
|
/** Low-level transport interface. */
|
|
101
101
|
export interface McpTransport {
|
|
102
102
|
start(): Promise<void>;
|
|
103
|
-
send(message: JsonRpcRequest | JsonRpcNotification): Promise<void>;
|
|
103
|
+
send(message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): Promise<void>;
|
|
104
104
|
/** Called once per incoming JSON-RPC message (response or server→client request/notification). */
|
|
105
105
|
onMessage(handler: (msg: JsonRpcResponse | JsonRpcNotification | JsonRpcRequest) => void): void;
|
|
106
106
|
onError(handler: (err: Error) => void): void;
|
package/dist/memory/phase1.js
CHANGED
|
@@ -160,6 +160,10 @@ function serializeSessionEntry(entry) {
|
|
|
160
160
|
return `[tool_call:${entry.toolCall.name}] ${truncate(entry.toolCall.arguments, 1_500)}`;
|
|
161
161
|
case "tool_result":
|
|
162
162
|
return `[tool_result${entry.message.isError ? " error=true" : ""}] ${truncate(entry.message.content, 2_000)}`;
|
|
163
|
+
case "provider_error":
|
|
164
|
+
// Operational diagnostics stay out of the memory-extraction prompt. The
|
|
165
|
+
// structured session record remains available for local debugging.
|
|
166
|
+
return "";
|
|
163
167
|
}
|
|
164
168
|
}
|
|
165
169
|
function countMeaningfulEntries(entries) {
|
package/dist/model-catalog.js
CHANGED
|
@@ -127,6 +127,8 @@ export const BUILTIN_MODELS = [
|
|
|
127
127
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", providerId: "anthropic", tier: "balanced", reasoningLevels: ANTHROPIC_SONNET_EFFORT_LEVELS, defaultReasoningLevel: "high", contextWindow: 1000000 },
|
|
128
128
|
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", providerId: "anthropic", tier: "fast", reasoningLevels: ANTHROPIC_CHAT_LEVELS, contextWindow: 200000 },
|
|
129
129
|
{ id: "deepseek-v4-flash", name: "deepseek-v4-flash", providerId: "deepseek", tier: "fast", reasoningLevels: DEEPSEEK_V4_LEVELS, defaultReasoningLevel: "high", contextWindow: 1048576 },
|
|
130
|
+
// DeepSeek Flash: retain the high effort used by the previous preview.
|
|
131
|
+
{ id: "deepseek-flash", name: "deepseek-flash", providerId: "deepseek", reasoningLevels: ["high"], defaultReasoningLevel: "high" },
|
|
130
132
|
// Experimental vision model stays explicit-only: it is text-capable, but
|
|
131
133
|
// automatic fast-tier routing must not silently switch ordinary subagents
|
|
132
134
|
// from the stable Flash model to an experimental multimodal endpoint.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "./types.js";
|
|
2
|
+
export interface SanitizedProviderError {
|
|
3
|
+
providerId: string;
|
|
4
|
+
modelId: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
thinkingLevel: ThinkingLevel;
|
|
7
|
+
name: string;
|
|
8
|
+
message: string;
|
|
9
|
+
httpStatus?: number;
|
|
10
|
+
code?: string;
|
|
11
|
+
parameter?: string;
|
|
12
|
+
messageCount: number;
|
|
13
|
+
toolCount: number;
|
|
14
|
+
bubbleVersion: string;
|
|
15
|
+
pid: number;
|
|
16
|
+
runtimeStartedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ProviderErrorContext {
|
|
19
|
+
providerId: string;
|
|
20
|
+
modelId: string;
|
|
21
|
+
model?: string;
|
|
22
|
+
thinkingLevel: ThinkingLevel;
|
|
23
|
+
messageCount: number;
|
|
24
|
+
toolCount: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the allowlisted diagnostic record written to a session log.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately do not serialize the original error object: SDK errors can
|
|
30
|
+
* contain request headers, request bodies, response bodies and stacks. Only
|
|
31
|
+
* scalar identifiers plus a locally-authored error category are retained.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createSanitizedProviderError(error: unknown, context: ProviderErrorContext): SanitizedProviderError;
|
|
34
|
+
export declare function sanitizeProviderErrorText(value: string): string;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { getCurrentVersion } from "./update/index.js";
|
|
2
|
+
const MAX_FIELD_CHARS = 160;
|
|
3
|
+
const RUNTIME_STARTED_AT = Date.now();
|
|
4
|
+
const SAFE_ERROR_CODES = new Map([
|
|
5
|
+
["authentication_error", "authentication_error"],
|
|
6
|
+
["bad_request", "bad_request"],
|
|
7
|
+
["context_length_exceeded", "context_length_exceeded"],
|
|
8
|
+
["failed_precondition", "failed_precondition"],
|
|
9
|
+
["internal_error", "internal_error"],
|
|
10
|
+
["invalid_argument", "invalid_argument"],
|
|
11
|
+
["invalidparameter", "invalid_parameter"],
|
|
12
|
+
["invalid_parameter", "invalid_parameter"],
|
|
13
|
+
["location_not_supported", "location_not_supported"],
|
|
14
|
+
["permission_denied", "permission_denied"],
|
|
15
|
+
["rate_limit_exceeded", "rate_limit_exceeded"],
|
|
16
|
+
["rate_limit_exhausted", "rate_limit_exhausted"],
|
|
17
|
+
["resource_exhausted", "resource_exhausted"],
|
|
18
|
+
["service_unavailable", "service_unavailable"],
|
|
19
|
+
["timeout", "timeout"],
|
|
20
|
+
["unauthenticated", "unauthenticated"],
|
|
21
|
+
["unsupported_parameter", "unsupported_parameter"],
|
|
22
|
+
["unsupported_value", "unsupported_value"],
|
|
23
|
+
]);
|
|
24
|
+
const SAFE_PARAMETER_NAMES = new Set([
|
|
25
|
+
"contents",
|
|
26
|
+
"frequency_penalty",
|
|
27
|
+
"generationConfig",
|
|
28
|
+
"input",
|
|
29
|
+
"max_output_tokens",
|
|
30
|
+
"max_tokens",
|
|
31
|
+
"messages",
|
|
32
|
+
"model",
|
|
33
|
+
"n",
|
|
34
|
+
"presence_penalty",
|
|
35
|
+
"prompt",
|
|
36
|
+
"reasoning_effort",
|
|
37
|
+
"response_format",
|
|
38
|
+
"seed",
|
|
39
|
+
"stop",
|
|
40
|
+
"stream",
|
|
41
|
+
"temperature",
|
|
42
|
+
"thinkingConfig",
|
|
43
|
+
"thinkingLevel",
|
|
44
|
+
"thinking_level",
|
|
45
|
+
"tool_choice",
|
|
46
|
+
"tools",
|
|
47
|
+
"topP",
|
|
48
|
+
"top_p",
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Build the allowlisted diagnostic record written to a session log.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately do not serialize the original error object: SDK errors can
|
|
54
|
+
* contain request headers, request bodies, response bodies and stacks. Only
|
|
55
|
+
* scalar identifiers plus a locally-authored error category are retained.
|
|
56
|
+
*/
|
|
57
|
+
export function createSanitizedProviderError(error, context) {
|
|
58
|
+
const sources = errorSources(error);
|
|
59
|
+
const rawMessage = firstString(sources, ["message"])
|
|
60
|
+
|| (typeof error === "string" ? error : "Provider request failed.");
|
|
61
|
+
const httpStatus = firstHttpStatus(sources);
|
|
62
|
+
const code = sanitizeErrorCode(firstScalarString(sources, ["code", "errorCode", "type"]));
|
|
63
|
+
const parameter = sanitizeParameterName(firstScalarString(sources, ["param", "parameter"]));
|
|
64
|
+
return {
|
|
65
|
+
providerId: sanitizeControlledIdentifier(context.providerId),
|
|
66
|
+
modelId: sanitizeControlledIdentifier(context.modelId),
|
|
67
|
+
...(context.model ? { model: sanitizeControlledIdentifier(context.model) } : {}),
|
|
68
|
+
thinkingLevel: context.thinkingLevel,
|
|
69
|
+
name: "ProviderError",
|
|
70
|
+
message: sanitizeProviderErrorText(rawMessage),
|
|
71
|
+
...(httpStatus !== undefined ? { httpStatus } : {}),
|
|
72
|
+
...(code ? { code } : {}),
|
|
73
|
+
...(parameter ? { parameter } : {}),
|
|
74
|
+
messageCount: Math.max(0, Math.trunc(context.messageCount)),
|
|
75
|
+
toolCount: Math.max(0, Math.trunc(context.toolCount)),
|
|
76
|
+
bubbleVersion: getCurrentVersion(),
|
|
77
|
+
pid: process.pid,
|
|
78
|
+
runtimeStartedAt: RUNTIME_STARTED_AT,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function sanitizeProviderErrorText(value) {
|
|
82
|
+
const text = value.replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim();
|
|
83
|
+
// Fail closed: provider/SDK messages can embed request headers, bodies,
|
|
84
|
+
// prompts, URLs or credentials in formats a denylist cannot anticipate.
|
|
85
|
+
// Persist only fixed, locally-authored summaries selected by broad error
|
|
86
|
+
// categories; never return any substring copied from the provider message.
|
|
87
|
+
if (/user location (?:is )?not supported|location[^.]{0,40}(?:unsupported|not supported)/i.test(text)) {
|
|
88
|
+
return "User location is not supported for API use.";
|
|
89
|
+
}
|
|
90
|
+
if (/context[_ -]?(?:length|window)|prompt (?:is )?too long|maximum context|token limit/i.test(text)) {
|
|
91
|
+
return "Provider context limit was exceeded.";
|
|
92
|
+
}
|
|
93
|
+
if (/rate limit|too many requests|quota|resource exhausted|\b429\b/i.test(text)) {
|
|
94
|
+
return "Provider rate limit was exceeded.";
|
|
95
|
+
}
|
|
96
|
+
if (/unauthori[sz]ed|authentication|invalid (?:api )?key|missing (?:api )?key|\b401\b/i.test(text)) {
|
|
97
|
+
return "Provider authentication failed.";
|
|
98
|
+
}
|
|
99
|
+
if (/forbidden|permission denied|access denied|\b403\b/i.test(text)) {
|
|
100
|
+
return "Provider denied the request.";
|
|
101
|
+
}
|
|
102
|
+
if (/invalid[^.]{0,60}(?:parameter|argument)|unsupported[^.]{0,60}(?:parameter|argument)|bad request|\b400\b/i.test(text)) {
|
|
103
|
+
return "Provider rejected a request parameter.";
|
|
104
|
+
}
|
|
105
|
+
if (/timed? ?out|timeout/i.test(text)) {
|
|
106
|
+
return "Provider request timed out.";
|
|
107
|
+
}
|
|
108
|
+
if (/network|connection|socket|fetch failed|stream (?:closed|interrupted)/i.test(text)) {
|
|
109
|
+
return "Provider connection failed.";
|
|
110
|
+
}
|
|
111
|
+
if (/abort|cancel/i.test(text)) {
|
|
112
|
+
return "Provider request was cancelled.";
|
|
113
|
+
}
|
|
114
|
+
return "Provider request failed.";
|
|
115
|
+
}
|
|
116
|
+
function errorSources(error) {
|
|
117
|
+
const sources = [];
|
|
118
|
+
const root = asRecord(error);
|
|
119
|
+
if (root)
|
|
120
|
+
sources.push(root);
|
|
121
|
+
const nested = asRecord(root?.error);
|
|
122
|
+
if (nested)
|
|
123
|
+
sources.push(nested);
|
|
124
|
+
const cause = asRecord(root?.cause);
|
|
125
|
+
if (cause)
|
|
126
|
+
sources.push(cause);
|
|
127
|
+
const causeError = asRecord(cause?.error);
|
|
128
|
+
if (causeError)
|
|
129
|
+
sources.push(causeError);
|
|
130
|
+
return sources;
|
|
131
|
+
}
|
|
132
|
+
function asRecord(value) {
|
|
133
|
+
return value !== null && (typeof value === "object" || typeof value === "function")
|
|
134
|
+
? value
|
|
135
|
+
: undefined;
|
|
136
|
+
}
|
|
137
|
+
function firstString(sources, keys) {
|
|
138
|
+
for (const source of sources) {
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const value = source[key];
|
|
141
|
+
if (typeof value === "string" && value.trim())
|
|
142
|
+
return value.trim();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
function firstScalarString(sources, keys) {
|
|
148
|
+
for (const source of sources) {
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
const value = source[key];
|
|
151
|
+
if ((typeof value === "string" || typeof value === "number") && String(value).trim()) {
|
|
152
|
+
return String(value).trim();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
function firstHttpStatus(sources) {
|
|
159
|
+
for (const source of sources) {
|
|
160
|
+
for (const key of ["status", "statusCode", "httpStatus"]) {
|
|
161
|
+
const value = source[key];
|
|
162
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
163
|
+
if (Number.isInteger(numeric) && numeric >= 100 && numeric <= 599)
|
|
164
|
+
return numeric;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
function sanitizeControlledIdentifier(value) {
|
|
170
|
+
const normalized = value.trim();
|
|
171
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(normalized))
|
|
172
|
+
return "unknown";
|
|
173
|
+
return normalized.slice(0, MAX_FIELD_CHARS);
|
|
174
|
+
}
|
|
175
|
+
function sanitizeErrorCode(value) {
|
|
176
|
+
if (!value)
|
|
177
|
+
return undefined;
|
|
178
|
+
return SAFE_ERROR_CODES.get(value.trim().toLowerCase());
|
|
179
|
+
}
|
|
180
|
+
function sanitizeParameterName(value) {
|
|
181
|
+
if (!value)
|
|
182
|
+
return undefined;
|
|
183
|
+
const normalized = value.trim();
|
|
184
|
+
return SAFE_PARAMETER_NAMES.has(normalized) ? normalized : undefined;
|
|
185
|
+
}
|
|
@@ -17,6 +17,7 @@ const KIMI_TOGGLE_THINKING_FAMILY = new Set(["kimi-k2.5", "kimi-k2.6"]);
|
|
|
17
17
|
const KIMI_K26_DEFAULT_MAX_TOKENS = 32768;
|
|
18
18
|
const MINIMAX_M3_FAMILY = new Set(["MiniMax-M3"]);
|
|
19
19
|
const DEEPSEEK_V4_FAMILY = new Set([
|
|
20
|
+
"deepseek-flash",
|
|
20
21
|
"deepseek-v4-flash",
|
|
21
22
|
"deepseek-v4-flash-vision-exp",
|
|
22
23
|
"deepseek-v4-pro",
|
package/dist/sdk/index.js
CHANGED
|
@@ -417,6 +417,11 @@ export class BubbleSdk {
|
|
|
417
417
|
if (message.role === "assistant")
|
|
418
418
|
recordMemoryCitations(cwd, message.content);
|
|
419
419
|
},
|
|
420
|
+
onProviderError: (error) => {
|
|
421
|
+
if (this.turnCoordinator.isDeleted(sessionId))
|
|
422
|
+
return;
|
|
423
|
+
session.appendProviderError(error);
|
|
424
|
+
},
|
|
420
425
|
onCompactionApplied: (summary) => {
|
|
421
426
|
if (this.turnCoordinator.isDeleted(sessionId))
|
|
422
427
|
return;
|
package/dist/session-log.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message } from "./types.js";
|
|
2
|
-
import type { SessionLogEntry, SessionMarkerKind, SessionMetadata, SessionSummaryEntry } from "./session-types.js";
|
|
2
|
+
import type { SessionLogEntry, SessionMarkerKind, SessionMetadata, SessionProviderErrorEntry, SessionSummaryEntry } from "./session-types.js";
|
|
3
|
+
import type { SanitizedProviderError } from "./provider-error-record.js";
|
|
3
4
|
export declare class SessionLog {
|
|
4
5
|
private entries;
|
|
5
6
|
load(lines: string[]): void;
|
|
@@ -10,5 +11,6 @@ export declare class SessionLog {
|
|
|
10
11
|
appendMessage(message: Message): SessionLogEntry[];
|
|
11
12
|
appendSummary(summary: string): SessionSummaryEntry;
|
|
12
13
|
appendMarker(kind: SessionMarkerKind, value: string): SessionLogEntry;
|
|
14
|
+
appendProviderError(error: SanitizedProviderError): SessionProviderErrorEntry;
|
|
13
15
|
toMessages(): Message[];
|
|
14
16
|
}
|
package/dist/session-log.js
CHANGED
|
@@ -71,6 +71,16 @@ export class SessionLog {
|
|
|
71
71
|
this.entries.push(entry);
|
|
72
72
|
return entry;
|
|
73
73
|
}
|
|
74
|
+
appendProviderError(error) {
|
|
75
|
+
const entry = {
|
|
76
|
+
id: nextEntryId(this.entries),
|
|
77
|
+
type: "provider_error",
|
|
78
|
+
error,
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
};
|
|
81
|
+
this.entries.push(entry);
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
74
84
|
toMessages() {
|
|
75
85
|
const messages = [];
|
|
76
86
|
let latestSummaryIndex = -1;
|
|
@@ -217,6 +227,7 @@ function isSessionLogEntry(entry) {
|
|
|
217
227
|
"assistant_message",
|
|
218
228
|
"tool_call",
|
|
219
229
|
"tool_result",
|
|
230
|
+
"provider_error",
|
|
220
231
|
].includes(entry.type);
|
|
221
232
|
}
|
|
222
233
|
function nextEntryId(entries) {
|
package/dist/session-types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AssistantMessage, Message, ThinkingLevel, ToolCall, ToolMessage, UserMessage } from "./types.js";
|
|
2
2
|
import type { GoalState } from "./goal/store.js";
|
|
3
|
+
import type { SanitizedProviderError } from "./provider-error-record.js";
|
|
3
4
|
export interface ExternalRuntimeSessionMetadata {
|
|
4
5
|
/** External agent runtime that owns the conversational state. */
|
|
5
6
|
id: "grok";
|
|
@@ -65,7 +66,11 @@ export interface SessionToolResultEntry extends BaseSessionLogEntry {
|
|
|
65
66
|
type: "tool_result";
|
|
66
67
|
message: ToolMessage;
|
|
67
68
|
}
|
|
68
|
-
export
|
|
69
|
+
export interface SessionProviderErrorEntry extends BaseSessionLogEntry {
|
|
70
|
+
type: "provider_error";
|
|
71
|
+
error: SanitizedProviderError;
|
|
72
|
+
}
|
|
73
|
+
export type SessionLogEntry = SessionMetadataEntry | SessionSummaryEntry | SessionMarkerEntry | SessionUserMessageEntry | SessionAssistantMessageEntry | SessionToolCallEntry | SessionToolResultEntry | SessionProviderErrorEntry;
|
|
69
74
|
export interface LegacySessionEntry {
|
|
70
75
|
id: string;
|
|
71
76
|
type: "metadata" | "message" | "compaction";
|
package/dist/session.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { CheckpointStore } from "./checkpoints.js";
|
|
|
5
5
|
import { type CompactOptions, type CompactResult } from "./context/compact.js";
|
|
6
6
|
import type { Message } from "./types.js";
|
|
7
7
|
import type { SessionLogEntry, SessionMarkerKind, SessionMetadata } from "./session-types.js";
|
|
8
|
+
import type { SanitizedProviderError } from "./provider-error-record.js";
|
|
8
9
|
export interface SessionSummary {
|
|
9
10
|
file: string;
|
|
10
11
|
name: string;
|
|
@@ -62,6 +63,7 @@ export declare class SessionManager {
|
|
|
62
63
|
appendMessage(message: Message): void;
|
|
63
64
|
appendCompaction(summary: string): void;
|
|
64
65
|
appendMarker(kind: SessionMarkerKind, value: string): void;
|
|
66
|
+
appendProviderError(error: SanitizedProviderError): void;
|
|
65
67
|
compact(options?: CompactOptions): CompactResult;
|
|
66
68
|
/**
|
|
67
69
|
* Inspect whether the session is large enough to compact and, if so, return
|
package/dist/session.js
CHANGED
|
@@ -172,6 +172,10 @@ export class SessionManager {
|
|
|
172
172
|
const entry = this.log.appendMarker(kind, value);
|
|
173
173
|
this.persist(entry);
|
|
174
174
|
}
|
|
175
|
+
appendProviderError(error) {
|
|
176
|
+
const entry = this.log.appendProviderError(error);
|
|
177
|
+
this.persist(entry);
|
|
178
|
+
}
|
|
175
179
|
compact(options) {
|
|
176
180
|
const result = compactSessionEntries(this.log.list(), options);
|
|
177
181
|
if (result.compacted && result.entries) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Persistent headless transport. One Agent + MCP lifecycle, sequential user turns. */
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import type { AgentEvent } from "./types.js";
|
|
4
|
+
interface StreamSessionOptions {
|
|
5
|
+
input: Readable;
|
|
6
|
+
write: (message: Record<string, unknown>) => void;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
model: string;
|
|
9
|
+
run: (prompt: string) => AsyncIterable<AgentEvent>;
|
|
10
|
+
onEvent?: (event: AgentEvent) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function runStreamJsonSession(options: StreamSessionOptions): Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { PrintRunCollector } from "./print-output.js";
|
|
2
|
+
const maxLineBytes = 1024 * 1024;
|
|
3
|
+
async function* lines(input) {
|
|
4
|
+
input.setEncoding("utf8");
|
|
5
|
+
let pending = "";
|
|
6
|
+
for await (const chunk of input) {
|
|
7
|
+
pending += chunk;
|
|
8
|
+
let index;
|
|
9
|
+
while ((index = pending.indexOf("\n")) >= 0) {
|
|
10
|
+
const line = pending.slice(0, index).replace(/\r$/, "");
|
|
11
|
+
pending = pending.slice(index + 1);
|
|
12
|
+
if (Buffer.byteLength(line) > maxLineBytes)
|
|
13
|
+
throw new Error("Input line exceeds 1 MiB");
|
|
14
|
+
if (line.trim())
|
|
15
|
+
yield line;
|
|
16
|
+
}
|
|
17
|
+
if (Buffer.byteLength(pending) > maxLineBytes)
|
|
18
|
+
throw new Error("Input line exceeds 1 MiB");
|
|
19
|
+
}
|
|
20
|
+
if (pending.trim())
|
|
21
|
+
yield pending;
|
|
22
|
+
}
|
|
23
|
+
function userText(value) {
|
|
24
|
+
if (typeof value === "string" && value.trim())
|
|
25
|
+
return value;
|
|
26
|
+
if (Array.isArray(value) && value.length && value.every(item => item && item.type === "text" && typeof item.text === "string")) {
|
|
27
|
+
const text = value.map(item => item.text).join("\n");
|
|
28
|
+
if (text.trim())
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
throw new Error("message.content must be non-empty text or text blocks");
|
|
32
|
+
}
|
|
33
|
+
export async function runStreamJsonSession(options) {
|
|
34
|
+
const { write, sessionId, model } = options;
|
|
35
|
+
write({ type: "system", subtype: "init", session_id: sessionId, model, protocol_version: 1 });
|
|
36
|
+
let sequence = 0;
|
|
37
|
+
// Read only as turns finish: the stream's own backpressure bounds queued input.
|
|
38
|
+
// EOF drains all accepted messages before main() shuts down MCP and the Agent.
|
|
39
|
+
for await (const line of lines(options.input)) {
|
|
40
|
+
let requestId;
|
|
41
|
+
let prompt;
|
|
42
|
+
try {
|
|
43
|
+
const message = JSON.parse(line);
|
|
44
|
+
if (typeof message?.request_id === "string")
|
|
45
|
+
requestId = message.request_id;
|
|
46
|
+
if (message?.type !== "user" || message.message?.role !== "user") {
|
|
47
|
+
throw new Error('Expected {type:"user",message:{role:"user",content:"..."}}');
|
|
48
|
+
}
|
|
49
|
+
if (message.session_id && message.session_id !== sessionId)
|
|
50
|
+
throw new Error("Session ID does not match this process");
|
|
51
|
+
prompt = userText(message.message.content);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
write({ type: "result", is_error: true, subtype: "invalid_input", session_id: sessionId,
|
|
55
|
+
request_id: requestId, result: error instanceof Error ? error.message : String(error) });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const collector = new PrintRunCollector();
|
|
59
|
+
const envelope = { session_id: sessionId, request_id: requestId, turn: ++sequence };
|
|
60
|
+
try {
|
|
61
|
+
for await (const event of options.run(prompt)) {
|
|
62
|
+
collector.onEvent(event);
|
|
63
|
+
options.onEvent?.(event);
|
|
64
|
+
write({ type: "stream_event", ...envelope, event });
|
|
65
|
+
}
|
|
66
|
+
const summary = collector.summary();
|
|
67
|
+
write({ type: "result", ...envelope, is_error: false, result: summary.text, stopReason: "end_turn", ...summary });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
write({ type: "result", ...envelope, is_error: true, stopReason: "error", ...collector.summary(),
|
|
71
|
+
result: error instanceof Error ? error.message : String(error) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -84,7 +84,8 @@ export function reduceAgentEvent(state, event, ctx) {
|
|
|
84
84
|
if (tc) {
|
|
85
85
|
tc.rawArguments = event.arguments;
|
|
86
86
|
tc.args = parsePartialArgs(event.arguments, tc.args);
|
|
87
|
-
|
|
87
|
+
// Parameter chunks are streaming data, like text/reasoning. Let the
|
|
88
|
+
// controller coalesce their paint instead of rendering for every byte.
|
|
88
89
|
return { state: { ...state, dirty: { ...state.dirty, tools: true } }, effects };
|
|
89
90
|
}
|
|
90
91
|
return { state, effects };
|
|
@@ -682,12 +682,13 @@ export class BubbleTuiController {
|
|
|
682
682
|
this.applyEffects(effects);
|
|
683
683
|
}
|
|
684
684
|
const hasDirty = Object.values(this.runState.dirty).some(Boolean);
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
685
|
+
// Streamed text, reasoning, and tool arguments share one 40ms paint.
|
|
686
|
+
// Repainting each argument chunk can starve input/timers while a
|
|
687
|
+
// buffered response drains, especially with a long reasoning preview.
|
|
688
|
+
// Tool lifecycle events still notify immediately through their effects.
|
|
689
|
+
// Dirty means "paint pending", not
|
|
689
690
|
// "this run has ever changed", and must never remain latched forever.
|
|
690
|
-
if (hasDirty && (event.type === "text_delta" || event.type === "reasoning_delta")) {
|
|
691
|
+
if (hasDirty && (event.type === "text_delta" || event.type === "reasoning_delta" || event.type === "tool_call_delta")) {
|
|
691
692
|
this.deps.ports.flush.scheduleFlush(STREAMING_FLUSH_INTERVAL_MS, () => {
|
|
692
693
|
if (this.disposed || !this.runActive)
|
|
693
694
|
return;
|