@bubblebrain-ai/bubble 0.0.19 → 0.0.20

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 (59) hide show
  1. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
  2. package/dist/agent/internal-reminder-sanitizer.js +46 -0
  3. package/dist/agent.d.ts +9 -0
  4. package/dist/agent.js +305 -17
  5. package/dist/approval/controller.d.ts +6 -0
  6. package/dist/approval/controller.js +104 -11
  7. package/dist/debug-trace.js +4 -0
  8. package/dist/feishu/agent-host/run-driver.js +28 -0
  9. package/dist/hooks/config.d.ts +9 -0
  10. package/dist/hooks/config.js +278 -0
  11. package/dist/hooks/controller.d.ts +24 -0
  12. package/dist/hooks/controller.js +254 -0
  13. package/dist/hooks/index.d.ts +6 -0
  14. package/dist/hooks/index.js +4 -0
  15. package/dist/hooks/log.d.ts +14 -0
  16. package/dist/hooks/log.js +54 -0
  17. package/dist/hooks/runner.d.ts +5 -0
  18. package/dist/hooks/runner.js +225 -0
  19. package/dist/hooks/trust.d.ts +37 -0
  20. package/dist/hooks/trust.js +143 -0
  21. package/dist/hooks/types.d.ts +173 -0
  22. package/dist/hooks/types.js +46 -0
  23. package/dist/main.js +32 -0
  24. package/dist/memory/prompts.js +3 -1
  25. package/dist/model-catalog.js +2 -0
  26. package/dist/model-pricing.js +8 -0
  27. package/dist/network/chatgpt-transport.d.ts +0 -1
  28. package/dist/network/chatgpt-transport.js +40 -121
  29. package/dist/network/provider-transport.d.ts +32 -0
  30. package/dist/network/provider-transport.js +265 -0
  31. package/dist/network/retry.d.ts +29 -0
  32. package/dist/network/retry.js +88 -0
  33. package/dist/network/system-proxy.d.ts +18 -0
  34. package/dist/network/system-proxy.js +175 -0
  35. package/dist/provider-anthropic.d.ts +1 -0
  36. package/dist/provider-anthropic.js +127 -52
  37. package/dist/provider-openai-codex.js +19 -29
  38. package/dist/session-log.js +3 -3
  39. package/dist/slash-commands/commands.js +84 -0
  40. package/dist/slash-commands/types.d.ts +2 -0
  41. package/dist/tools/edit-apply.js +63 -3
  42. package/dist/tools/edit.js +4 -4
  43. package/dist/tui/display-history.d.ts +4 -3
  44. package/dist/tui/display-history.js +34 -57
  45. package/dist/tui/display-sanitizer.d.ts +3 -0
  46. package/dist/tui/display-sanitizer.js +38 -0
  47. package/dist/tui/paste-placeholder.d.ts +1 -0
  48. package/dist/tui/paste-placeholder.js +7 -0
  49. package/dist/tui/run.d.ts +2 -0
  50. package/dist/tui/run.js +260 -155
  51. package/dist/tui/trace-groups.js +40 -4
  52. package/dist/tui/wordmark.d.ts +1 -0
  53. package/dist/tui/wordmark.js +56 -54
  54. package/dist/tui-ink/app.js +2 -1
  55. package/dist/tui-ink/trace-groups.js +40 -4
  56. package/dist/tui-opentui/app.js +2 -1
  57. package/dist/tui-opentui/trace-groups.js +40 -4
  58. package/dist/types.d.ts +27 -0
  59. package/package.json +1 -1
@@ -2,6 +2,7 @@ import type { AssistantProviderMetadata } from "../types.js";
2
2
  export declare function formatInternalReminderBlock(kind: string, content: string): string;
3
3
  export declare function formatInternalContextBlock(kind: string, content: string): string;
4
4
  export declare function sanitizeInternalReminderBlocks(text: string): string;
5
+ export declare function sanitizeInternalReasoningText(text: string): string;
5
6
  export declare function sanitizeAssistantProviderMetadata(metadata: AssistantProviderMetadata | undefined): AssistantProviderMetadata | undefined;
6
7
  export declare function createStreamingInternalReminderSanitizer(): {
7
8
  push(delta: string): string;
@@ -1,4 +1,5 @@
1
1
  const INTERNAL_TAG_PREFIX = "<bubble_internal_";
2
+ const MEMORY_CITATION_TAG = "<oai-mem-citation";
2
3
  const INTERNAL_TAG_NAMES = ["reminder", "context"];
3
4
  const LEGACY_RUNTIME_MARKERS = [
4
5
  "Runtime reminder:\n",
@@ -6,6 +7,7 @@ const LEGACY_RUNTIME_MARKERS = [
6
7
  ];
7
8
  const STREAM_MARKERS = [
8
9
  INTERNAL_TAG_PREFIX,
10
+ MEMORY_CITATION_TAG,
9
11
  ...LEGACY_RUNTIME_MARKERS,
10
12
  ];
11
13
  const LEGACY_REMINDER_END_PHRASES = [
@@ -37,6 +39,15 @@ export function sanitizeInternalReminderBlocks(text) {
37
39
  const sanitizer = createStreamingInternalReminderSanitizer();
38
40
  return sanitizer.push(text) + sanitizer.flush();
39
41
  }
42
+ export function sanitizeInternalReasoningText(text) {
43
+ const withoutBlocks = sanitizeInternalReminderBlocks(text);
44
+ if (!withoutBlocks)
45
+ return withoutBlocks;
46
+ return withoutBlocks
47
+ .split(/\n{2,}/)
48
+ .filter((paragraph) => !containsInternalReminderReference(paragraph))
49
+ .join("\n\n");
50
+ }
40
51
  export function sanitizeAssistantProviderMetadata(metadata) {
41
52
  const anthropic = metadata?.anthropic;
42
53
  const blocks = anthropic?.contentBlocks;
@@ -119,10 +130,31 @@ function formatInternalBlock(type, kind, content) {
119
130
  const safeKind = kind.replace(/[^a-zA-Z0-9_-]/g, "-");
120
131
  return `<bubble_internal_${type} kind="${safeKind}">\n${content}\n</bubble_internal_${type}>`;
121
132
  }
133
+ function containsInternalReminderReference(text) {
134
+ return INTERNAL_REASONING_REFERENCE_PATTERNS.some((pattern) => pattern.test(text));
135
+ }
136
+ const INTERNAL_REASONING_REFERENCE_PATTERNS = [
137
+ /<bubble_internal_(?:reminder|context)\b/i,
138
+ /\bsystem\s+reminder\b/i,
139
+ /\bruntime\s+reminder\b/i,
140
+ /\bsystem\s+prompt\b/i,
141
+ /The following deferred tools are available via tool_search/i,
142
+ /Known deferred tools/i,
143
+ /\bdeferred tools\b/i,
144
+ /\bmcp__[a-z0-9_]+/i,
145
+ /\bMCP\s+arxiv\s+tools\b/i,
146
+ /\barxiv\s+MCP\s+tools\b/i,
147
+ /Subagent lifecycle truth/i,
148
+ /Count unique agent_id values only/i,
149
+ /Do not describe a subagent as running or still working/i,
150
+ ];
122
151
  function consumeInternalBlockAtStart(text, final) {
123
152
  if (text.startsWith(INTERNAL_TAG_PREFIX)) {
124
153
  return consumeStructuredInternalBlock(text, final);
125
154
  }
155
+ if (text.startsWith(MEMORY_CITATION_TAG)) {
156
+ return consumeMemoryCitationBlock(text, final);
157
+ }
126
158
  if (text.startsWith("Runtime reminder:\n")) {
127
159
  return consumeLegacyRuntimeReminder(text, final);
128
160
  }
@@ -131,6 +163,20 @@ function consumeInternalBlockAtStart(text, final) {
131
163
  }
132
164
  return undefined;
133
165
  }
166
+ function consumeMemoryCitationBlock(text, final) {
167
+ const openMatch = text.match(/^<oai-mem-citation\b[^>]*>/);
168
+ if (!openMatch) {
169
+ return isPrefixOf(MEMORY_CITATION_TAG, text)
170
+ ? final ? { consume: text.length } : { hold: true }
171
+ : undefined;
172
+ }
173
+ const closeTag = "</oai-mem-citation>";
174
+ const closeIndex = text.indexOf(closeTag, openMatch[0].length);
175
+ if (closeIndex < 0) {
176
+ return final ? { consume: text.length } : { hold: true };
177
+ }
178
+ return { consume: consumeTrailingLineBreaks(text, closeIndex + closeTag.length) };
179
+ }
134
180
  function consumeStructuredInternalBlock(text, final) {
135
181
  for (const tagName of INTERNAL_TAG_NAMES) {
136
182
  const openMatch = text.match(new RegExp(`^<bubble_internal_${tagName}\\b[^>]*>`));
package/dist/agent.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { type ContextUsageSnapshot } from "./context/usage.js";
6
6
  import type { AgentEvent, AgentInputController, ContentPart, PermissionMode, Message, Provider, ThinkingLevel, Todo, ToolResult, ToolRegistryEntry, ToolUpdate } from "./types.js";
7
7
  import { type TurnHooks } from "./orchestrator/hooks.js";
8
+ import type { ExternalHookController } from "./hooks/controller.js";
8
9
  import { type AgentCategoriesConfig, type ResolvedSubagentRoute } from "./agent/categories.js";
9
10
  import { BudgetLedger } from "./agent/budget-ledger.js";
10
11
  import { type AgentProfile, type SubagentRunResult } from "./agent/profiles.js";
@@ -35,6 +36,9 @@ export interface AgentOptions {
35
36
  onTodosUpdate?: (todos: Todo[]) => void;
36
37
  onModeUpdate?: (mode: PermissionMode) => void;
37
38
  hooks?: TurnHooks[];
39
+ externalHooks?: ExternalHookController;
40
+ agentRole?: "parent" | "subagent";
41
+ subAgentId?: string;
38
42
  budgetLedger?: BudgetLedger;
39
43
  budgetSource?: {
40
44
  runId: string;
@@ -69,6 +73,9 @@ export declare class Agent {
69
73
  private onMessageAppend?;
70
74
  private onToolResult?;
71
75
  private hookDefinitions;
76
+ private externalHooks?;
77
+ private agentRole;
78
+ private subAgentId?;
72
79
  private maxTurns?;
73
80
  private taskBudget?;
74
81
  private budgetLedger?;
@@ -83,6 +90,8 @@ export declare class Agent {
83
90
  private lastInputTokens;
84
91
  private lastAnchorMessageCount;
85
92
  constructor(options: AgentOptions);
93
+ private runExternalHook;
94
+ private injectHookModelContext;
86
95
  /** Unlock a list of deferred tools so they're included in subsequent turns. */
87
96
  unlockDeferredTools(names: string[]): void;
88
97
  /** All deferred tools in this session (for tool_search to inspect). */
package/dist/agent.js CHANGED
@@ -8,11 +8,13 @@ import { compactMessagesWithLLM } from "./context/compact-llm.js";
8
8
  import { estimateContextTokens, getContextBudget } from "./context/budget.js";
9
9
  import { buildContextUsageSnapshot } from "./context/usage.js";
10
10
  import { isContextOverflowError } from "./context/overflow.js";
11
+ import { computeRetryDelayMs, isProviderStreamInterruption, MAX_STREAM_INTERRUPTION_RETRIES, sleepBeforeRetry, } from "./network/retry.js";
11
12
  import { projectMessages } from "./context/projector.js";
12
13
  import { aggressivePruneMessages, markStableCurrentToolResultsForCache } from "./context/prune.js";
13
14
  import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
14
15
  import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
15
16
  import { HookBus } from "./orchestrator/hooks.js";
17
+ import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
16
18
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
17
19
  import { resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
18
20
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
@@ -20,7 +22,7 @@ import { composeAbortSignals } from "./agent/budget-ledger.js";
20
22
  import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
21
23
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
22
24
  import { isHiddenToolResult } from "./agent/discovery-barrier.js";
23
- import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
25
+ import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
24
26
  import { buildSystemPrompt } from "./system-prompt.js";
25
27
  import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
26
28
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
@@ -39,6 +41,38 @@ const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained
39
41
  "Do not put the final answer only in hidden reasoning.";
40
42
  const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
41
43
  const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
44
+ function agentEventFromHookProgress(event) {
45
+ const source = `${event.source.scope}:${event.source.index}`;
46
+ if (event.type === "hook_start") {
47
+ return {
48
+ type: "hook_start",
49
+ eventName: event.eventName,
50
+ hookId: event.hookId,
51
+ source,
52
+ };
53
+ }
54
+ if (event.type === "hook_end") {
55
+ return {
56
+ type: "hook_end",
57
+ eventName: event.eventName,
58
+ hookId: event.hookId,
59
+ source,
60
+ elapsedMs: event.elapsedMs ?? 0,
61
+ decision: event.decision ?? "allow",
62
+ reason: event.reason,
63
+ };
64
+ }
65
+ return {
66
+ type: "hook_error",
67
+ eventName: event.eventName,
68
+ hookId: event.hookId,
69
+ source,
70
+ elapsedMs: event.elapsedMs,
71
+ decision: event.decision,
72
+ reason: event.reason,
73
+ error: event.error ?? "Hook failed.",
74
+ };
75
+ }
42
76
  export class AgentAbortError extends Error {
43
77
  constructor(message = "Agent run cancelled.") {
44
78
  super(message);
@@ -64,6 +98,9 @@ export class Agent {
64
98
  onMessageAppend;
65
99
  onToolResult;
66
100
  hookDefinitions;
101
+ externalHooks;
102
+ agentRole;
103
+ subAgentId;
67
104
  maxTurns;
68
105
  taskBudget;
69
106
  budgetLedger;
@@ -91,6 +128,9 @@ export class Agent {
91
128
  this.onTodosUpdate = options.onTodosUpdate;
92
129
  this.onModeUpdate = options.onModeUpdate;
93
130
  this.hookDefinitions = options.hooks ?? [];
131
+ this.externalHooks = options.externalHooks;
132
+ this.agentRole = options.agentRole ?? "parent";
133
+ this.subAgentId = options.subAgentId;
94
134
  this.maxTurns = options.maxTurns ?? options.steps;
95
135
  this.taskBudget = options.taskBudget;
96
136
  this.budgetLedger = options.budgetLedger;
@@ -120,6 +160,37 @@ export class Agent {
120
160
  this.injectSystemReminder(buildDeferredToolsReminder(deferredNames));
121
161
  }
122
162
  }
163
+ async runExternalHook(request, abortSignal) {
164
+ const events = [];
165
+ if (!this.externalHooks) {
166
+ return {
167
+ result: {
168
+ eventName: request.eventName,
169
+ decision: "allow",
170
+ modelContext: [],
171
+ results: [],
172
+ diagnostics: [],
173
+ matched: 0,
174
+ },
175
+ events,
176
+ };
177
+ }
178
+ const result = await this.externalHooks.runEvent({
179
+ agentRole: this.agentRole,
180
+ subAgentId: this.subAgentId,
181
+ sessionId: this.sessionID,
182
+ ...request,
183
+ }, {
184
+ abortSignal,
185
+ onProgress: (event) => events.push(agentEventFromHookProgress(event)),
186
+ });
187
+ return { result, events };
188
+ }
189
+ injectHookModelContext(result) {
190
+ for (const context of result.modelContext) {
191
+ this.injectSystemReminder(`[Hook ${result.eventName}] ${context}`);
192
+ }
193
+ }
123
194
  /** Unlock a list of deferred tools so they're included in subsequent turns. */
124
195
  unlockDeferredTools(names) {
125
196
  for (const n of names) {
@@ -259,6 +330,7 @@ export class Agent {
259
330
  provider: this._providerId || "none",
260
331
  model: this.apiModel || "none",
261
332
  };
333
+ const runId = randomUUID();
262
334
  const emit = (event) => {
263
335
  traceEvent("agent_event", summarizeAgentEventForTrace(event), traceContext);
264
336
  return event;
@@ -284,37 +356,68 @@ export class Agent {
284
356
  reminderQueue.push(reminder);
285
357
  };
286
358
  const pendingInputCount = () => inputController?.pendingInputCount() ?? 0;
287
- const applyPendingInputs = () => {
359
+ const applyPendingInputs = async () => {
288
360
  const pendingInputs = inputController?.drainPendingInputs() ?? [];
289
361
  if (pendingInputs.length === 0)
290
362
  return [];
363
+ const events = [];
291
364
  for (const input of pendingInputs) {
365
+ const hook = await this.runExternalHook({
366
+ eventName: "SteerInputApplied",
367
+ cwd,
368
+ runId,
369
+ target: "current_turn",
370
+ payload: {
371
+ id: input.id,
372
+ target: "current_turn",
373
+ ...normalizeHookInput(input.content),
374
+ },
375
+ fullPayload: { prompt: input.content },
376
+ }, abortSignal);
377
+ events.push(...hook.events);
378
+ this.injectHookModelContext(hook.result);
292
379
  this.appendMessage({ role: "user", content: input.content });
293
- }
294
- return [
295
- ...pendingInputs.map((input) => ({
380
+ events.push({
296
381
  type: "input_applied",
297
382
  id: input.id,
298
383
  content: input.content,
299
384
  target: "current_turn",
300
- })),
301
- { type: "input_pending_changed", pending: pendingInputCount() },
302
- ];
385
+ });
386
+ }
387
+ events.push({ type: "input_pending_changed", pending: pendingInputCount() });
388
+ return events;
303
389
  };
304
- const rejectPendingInputs = (reason) => {
390
+ const rejectPendingInputs = async (reason) => {
305
391
  const pendingInputs = inputController?.drainPendingInputs() ?? [];
306
392
  if (pendingInputs.length === 0)
307
393
  return [];
308
- return [
309
- ...pendingInputs.map((input) => ({
394
+ const events = [];
395
+ for (const input of pendingInputs) {
396
+ const hook = await this.runExternalHook({
397
+ eventName: "QueuedInputRejected",
398
+ cwd,
399
+ runId,
400
+ target: "next_turn",
401
+ payload: {
402
+ id: input.id,
403
+ reason,
404
+ target: "next_turn",
405
+ ...normalizeHookInput(input.content),
406
+ },
407
+ fullPayload: { prompt: input.content },
408
+ }, abortSignal);
409
+ events.push(...hook.events);
410
+ this.injectHookModelContext(hook.result);
411
+ events.push({
310
412
  type: "input_rejected",
311
413
  id: input.id,
312
414
  content: input.content,
313
415
  reason,
314
416
  target: "next_turn",
315
- })),
316
- { type: "input_pending_changed", pending: pendingInputCount() },
317
- ];
417
+ });
418
+ }
419
+ events.push({ type: "input_pending_changed", pending: pendingInputCount() });
420
+ return events;
318
421
  };
319
422
  const flushGovernorReminders = () => {
320
423
  for (const reminder of reminderQueue.splice(0, reminderQueue.length)) {
@@ -325,6 +428,26 @@ export class Agent {
325
428
  this.setTodos([]);
326
429
  yield emit({ type: "todos_updated", todos: [] });
327
430
  }
431
+ const promptHook = await this.runExternalHook({
432
+ eventName: "UserPromptSubmit",
433
+ cwd,
434
+ runId,
435
+ target: typeof userInput === "string" ? userInput : "content_parts",
436
+ payload: normalizeHookInput(userInput),
437
+ fullPayload: { prompt: userInput },
438
+ }, abortSignal);
439
+ for (const event of promptHook.events)
440
+ yield emit(event);
441
+ if (promptHook.result.decision === "deny") {
442
+ const message = promptHook.result.reason
443
+ ?? `Prompt blocked by hook ${promptHook.result.sourceHookId ?? "<unknown>"}.`;
444
+ yield emit({ type: "turn_start" });
445
+ yield emit({ type: "text_delta", content: message });
446
+ yield emit({ type: "turn_end", willContinue: false });
447
+ yield emit({ type: "agent_end" });
448
+ return;
449
+ }
450
+ this.injectHookModelContext(promptHook.result);
328
451
  this.appendMessage({ role: "user", content: userInput });
329
452
  await hookBus.runBeforeTurn({
330
453
  agent: this,
@@ -337,6 +460,7 @@ export class Agent {
337
460
  flushGovernorReminders();
338
461
  let consecutiveOverflowRecoveries = 0;
339
462
  let consecutiveEmptyAssistantRecoveries = 0;
463
+ let consecutiveStreamInterruptionRetries = 0;
340
464
  let step = 0;
341
465
  let autoServersStopped = false;
342
466
  const stopOwnedAutoServers = async () => {
@@ -353,7 +477,7 @@ export class Agent {
353
477
  flushGovernorReminders();
354
478
  for (const update of this.drainSubagentToolUpdates())
355
479
  yield emit(update);
356
- for (const event of applyPendingInputs())
480
+ for (const event of await applyPendingInputs())
357
481
  yield emit(event);
358
482
  yield emit({ type: "turn_start" });
359
483
  step += 1;
@@ -404,6 +528,23 @@ export class Agent {
404
528
  };
405
529
  await hookBus.runBeforeModelCall(beforeModelCallCtx);
406
530
  toolEntries = beforeModelCallCtx.toolEntries;
531
+ const preModelHook = await this.runExternalHook({
532
+ eventName: "PreModelCall",
533
+ cwd,
534
+ runId,
535
+ target: this.apiModel,
536
+ payload: {
537
+ providerId: this.providerId,
538
+ model: this.apiModel,
539
+ mode: this._mode,
540
+ toolCount: toolEntries.length,
541
+ ...normalizeHookInput(userInput),
542
+ },
543
+ fullPayload: { prompt: userInput },
544
+ }, abortSignal);
545
+ for (const event of preModelHook.events)
546
+ yield emit(event);
547
+ this.injectHookModelContext(preModelHook.result);
407
548
  flushGovernorReminders();
408
549
  const textOnly = !!hookState.forceTextOnlyReason;
409
550
  const toolDefinitions = toolEntries
@@ -610,6 +751,23 @@ export class Agent {
610
751
  if (assistantAppended) {
611
752
  throw error;
612
753
  }
754
+ if (isProviderStreamInterruption(error)
755
+ && !isAbortLikeError(error, abortSignal)
756
+ && consecutiveStreamInterruptionRetries < MAX_STREAM_INTERRUPTION_RETRIES) {
757
+ // The provider stream died after partial content. The half-built
758
+ // assistantMsg was never appended to this.messages, and the next
759
+ // turn_start resets the streaming display, so re-issuing the whole
760
+ // request is safe.
761
+ consecutiveStreamInterruptionRetries += 1;
762
+ yield emit({
763
+ type: "provider_retry",
764
+ attempt: consecutiveStreamInterruptionRetries,
765
+ maxAttempts: MAX_STREAM_INTERRUPTION_RETRIES,
766
+ reason: "Provider stream interrupted mid-response.",
767
+ });
768
+ await sleepBeforeRetry(computeRetryDelayMs(consecutiveStreamInterruptionRetries), abortSignal).catch(() => undefined);
769
+ continue;
770
+ }
613
771
  if (!isContextOverflowError(error)) {
614
772
  if (!isAbortLikeError(error, abortSignal) && shouldAppendModelInterruptedBoundary(this.messages)) {
615
773
  this.appendMessage(createModelInterruptedMessage(error, {
@@ -631,6 +789,7 @@ export class Agent {
631
789
  }
632
790
  consecutiveOverflowRecoveries = 0;
633
791
  consecutiveEmptyAssistantRecoveries = 0;
792
+ consecutiveStreamInterruptionRetries = 0;
634
793
  // Execute tools if any
635
794
  if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
636
795
  const parsedCalls = [];
@@ -695,6 +854,37 @@ export class Agent {
695
854
  blockedResult = result;
696
855
  },
697
856
  });
857
+ const preToolHook = await this.runExternalHook({
858
+ eventName: "PreToolUse",
859
+ cwd,
860
+ runId,
861
+ target: tc.name,
862
+ payload: {
863
+ id: tc.id,
864
+ name: tc.name,
865
+ argsPreview: truncateHookText(tc.arguments, 1000),
866
+ },
867
+ fullPayload: {
868
+ toolArgs: tc.parsedArgs,
869
+ toolArguments: tc.arguments,
870
+ },
871
+ }, abortSignal);
872
+ for (const event of preToolHook.events)
873
+ yield emit(event);
874
+ this.injectHookModelContext(preToolHook.result);
875
+ if (preToolHook.result.decision === "deny") {
876
+ blockedResult = {
877
+ content: preToolHook.result.reason
878
+ ?? `Tool call blocked by hook ${preToolHook.result.sourceHookId ?? "<unknown>"}.`,
879
+ isError: true,
880
+ metadata: {
881
+ hook: {
882
+ eventName: "PreToolUse",
883
+ hookId: preToolHook.result.sourceHookId,
884
+ },
885
+ },
886
+ };
887
+ }
698
888
  assistantMsg.toolCalls[index] = {
699
889
  id: tc.id,
700
890
  name: tc.name,
@@ -729,6 +919,27 @@ export class Agent {
729
919
  result = next;
730
920
  },
731
921
  });
922
+ const postToolHook = await this.runExternalHook({
923
+ eventName: result.isError ? "PostToolUseFailure" : "PostToolUse",
924
+ cwd,
925
+ runId,
926
+ target: tc.name,
927
+ payload: {
928
+ id: tc.id,
929
+ name: tc.name,
930
+ argsPreview: truncateHookText(tc.arguments, 1000),
931
+ resultPreview: truncateHookText(result.content, 1000),
932
+ isError: result.isError === true,
933
+ },
934
+ fullPayload: {
935
+ toolArgs: tc.parsedArgs,
936
+ toolArguments: tc.arguments,
937
+ toolResult: result,
938
+ },
939
+ }, abortSignal);
940
+ for (const event of postToolHook.events)
941
+ yield emit(event);
942
+ this.injectHookModelContext(postToolHook.result);
732
943
  traceEvent("speculative_read_blocked", {
733
944
  id: tc.id,
734
945
  name: tc.name,
@@ -814,6 +1025,27 @@ export class Agent {
814
1025
  result = next;
815
1026
  },
816
1027
  });
1028
+ const postToolHook = await this.runExternalHook({
1029
+ eventName: result.isError ? "PostToolUseFailure" : "PostToolUse",
1030
+ cwd,
1031
+ runId,
1032
+ target: tc.name,
1033
+ payload: {
1034
+ id: tc.id,
1035
+ name: tc.name,
1036
+ argsPreview: truncateHookText(tc.arguments, 1000),
1037
+ resultPreview: truncateHookText(result.content, 1000),
1038
+ isError: result.isError === true,
1039
+ },
1040
+ fullPayload: {
1041
+ toolArgs: tc.parsedArgs,
1042
+ toolArguments: tc.arguments,
1043
+ toolResult: result,
1044
+ },
1045
+ }, abortSignal);
1046
+ for (const event of postToolHook.events)
1047
+ yield emit(event);
1048
+ this.injectHookModelContext(postToolHook.result);
817
1049
  // Honor the model's server-declared per-tool-output token cap (e.g.
818
1050
  // gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
819
1051
  // blow past the input window even though our local estimate looks fine.
@@ -885,13 +1117,28 @@ export class Agent {
885
1117
  flushReminders: flushGovernorReminders,
886
1118
  });
887
1119
  flushGovernorReminders();
1120
+ const stopHook = await this.runExternalHook({
1121
+ eventName: "Stop",
1122
+ cwd,
1123
+ runId,
1124
+ target: "turn",
1125
+ payload: {
1126
+ providerId: this.providerId,
1127
+ model: this.apiModel,
1128
+ mode: this._mode,
1129
+ assistantChars: assistantMsg.content.length,
1130
+ toolCalls: assistantMsg.toolCalls?.length ?? 0,
1131
+ },
1132
+ }, abortSignal);
1133
+ for (const event of stopHook.events)
1134
+ yield emit(event);
888
1135
  const willContinue = !!hookState.forceContinuationReason;
889
1136
  yield emit({ type: "turn_end", usage: turnUsage, willContinue });
890
1137
  if (willContinue) {
891
1138
  delete hookState.forceContinuationReason;
892
1139
  continue;
893
1140
  }
894
- for (const event of rejectPendingInputs("no_continuation"))
1141
+ for (const event of await rejectPendingInputs("no_continuation"))
895
1142
  yield emit(event);
896
1143
  break;
897
1144
  }
@@ -913,6 +1160,19 @@ export class Agent {
913
1160
  yield emit({ type: "todos_updated", todos: this.getTodos() });
914
1161
  }
915
1162
  }
1163
+ else {
1164
+ const stopFailureHook = await this.runExternalHook({
1165
+ eventName: "StopFailure",
1166
+ cwd,
1167
+ runId,
1168
+ target: "run_error",
1169
+ payload: {
1170
+ error: summarizeTraceError(error),
1171
+ },
1172
+ }, abortSignal);
1173
+ for (const event of stopFailureHook.events)
1174
+ yield emit(event);
1175
+ }
916
1176
  throw error;
917
1177
  }
918
1178
  finally {
@@ -1226,6 +1486,26 @@ export class Agent {
1226
1486
  this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
1227
1487
  }
1228
1488
  };
1489
+ const runSubagentLifecycleHook = async (eventName, status, error) => {
1490
+ try {
1491
+ await this.runExternalHook({
1492
+ eventName,
1493
+ cwd,
1494
+ runId: record.runId,
1495
+ target: record.profile.name,
1496
+ payload: {
1497
+ agentId: record.agentId,
1498
+ nickname: record.nickname,
1499
+ profile: record.profile.name,
1500
+ status,
1501
+ error,
1502
+ },
1503
+ }, options.abortSignal);
1504
+ }
1505
+ catch {
1506
+ // Subagent lifecycle hooks are observe-only; never fail the subagent.
1507
+ }
1508
+ };
1229
1509
  const allTools = [...this.tools.values()];
1230
1510
  const diagnostics = validateAgentProfileTools(allTools, record.profile, options.approval);
1231
1511
  const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
@@ -1236,6 +1516,7 @@ export class Agent {
1236
1516
  record.status = "blocked";
1237
1517
  record.error = blockingDiagnostics.map((diagnostic) => diagnostic.message).join("\n");
1238
1518
  record.updatedAt = Date.now();
1519
+ await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1239
1520
  emit("blocked", undefined, record.error);
1240
1521
  this.notifySubagentWaiters(record);
1241
1522
  return;
@@ -1251,6 +1532,7 @@ export class Agent {
1251
1532
  record.status = "blocked";
1252
1533
  record.error = error?.message || String(error);
1253
1534
  record.updatedAt = Date.now();
1535
+ await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1254
1536
  emit("blocked", undefined, record.error);
1255
1537
  this.notifySubagentWaiters(record);
1256
1538
  return;
@@ -1258,6 +1540,7 @@ export class Agent {
1258
1540
  record.agent = subAgent;
1259
1541
  record.status = "running";
1260
1542
  record.updatedAt = Date.now();
1543
+ await runSubagentLifecycleHook("SubagentStart", record.status);
1261
1544
  emit("running", undefined, `Running ${record.nickname} (${record.profile.name})...`);
1262
1545
  let turnSummaryBuffer = "";
1263
1546
  let turnHadToolCall = false;
@@ -1304,6 +1587,7 @@ export class Agent {
1304
1587
  record.summary = sanitizeSubagentSummary(record.summary);
1305
1588
  record.error = error?.message || String(error);
1306
1589
  record.updatedAt = Date.now();
1590
+ await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1307
1591
  emit(record.status, undefined, record.error);
1308
1592
  this.notifySubagentWaiters(record);
1309
1593
  return;
@@ -1315,6 +1599,7 @@ export class Agent {
1315
1599
  record.status = "completed";
1316
1600
  record.summary = sanitizeSubagentSummary(record.summary);
1317
1601
  record.updatedAt = Date.now();
1602
+ await runSubagentLifecycleHook("SubagentStop", record.status);
1318
1603
  emit("completed", undefined, record.summary || `${record.nickname} completed`);
1319
1604
  this.notifySubagentWaiters(record);
1320
1605
  }
@@ -1394,6 +1679,9 @@ export class Agent {
1394
1679
  budgetSource: { runId: record.runId, subAgentId: record.agentId },
1395
1680
  systemPrompt: childSystemPrompt,
1396
1681
  hooks: this.hookDefinitions,
1682
+ externalHooks: this.externalHooks,
1683
+ agentRole: "subagent",
1684
+ subAgentId: record.agentId,
1397
1685
  agentCategories: this.agentCategories,
1398
1686
  providerFactory: this.providerFactory,
1399
1687
  });
@@ -1550,7 +1838,7 @@ export class Agent {
1550
1838
  message.content = sanitizeInternalReminderBlocks(message.content);
1551
1839
  }
1552
1840
  if (message.role === "assistant" && message.reasoning) {
1553
- message.reasoning = sanitizeInternalReminderBlocks(message.reasoning);
1841
+ message.reasoning = sanitizeInternalReasoningText(message.reasoning);
1554
1842
  }
1555
1843
  if (message.role === "assistant" && message.providerMetadata) {
1556
1844
  message.providerMetadata = sanitizeAssistantProviderMetadata(message.providerMetadata);
@@ -1,5 +1,6 @@
1
1
  import type { PermissionCheckResult, PermissionQuery, PermissionRuleSet } from "../permissions/types.js";
2
2
  import type { PermissionMode } from "../types.js";
3
+ import type { ExternalHookController } from "../hooks/controller.js";
3
4
  import type { BashAllowlist } from "./session-cache.js";
4
5
  import type { ApprovalController, ApprovalDecision, ApprovalRequest } from "./types.js";
5
6
  export interface ApprovalControllerOptions {
@@ -23,6 +24,9 @@ export interface ApprovalControllerOptions {
23
24
  * /permissions take effect immediately. Omit to disable rule-based gating.
24
25
  */
25
26
  getRuleSet?: () => PermissionRuleSet;
27
+ /** External lifecycle hooks may observe or reject pending permission requests. */
28
+ externalHooks?: ExternalHookController;
29
+ sessionId?: string;
26
30
  }
27
31
  /**
28
32
  * Default ApprovalController. Decision tree:
@@ -46,4 +50,6 @@ export declare class PermissionAwareApprovalController implements ApprovalContro
46
50
  request(req: ApprovalRequest): Promise<ApprovalDecision>;
47
51
  private requestToQuery;
48
52
  private checkRequestRules;
53
+ private runPermissionRequestHook;
54
+ private runPermissionResultHook;
49
55
  }