@pasko70/pibo 1.7.11 → 1.8.0

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.
@@ -1,7 +1,92 @@
1
1
  import { DEFAULT_COMPACTION_SETTINGS, buildSessionContext, estimateTokens, } from "@earendil-works/pi-coding-agent";
2
2
  export const PIBO_CONTEXT_GUARD_NOTICE = "Context safety interrupted this response before adding it to long-term context. Pibo is compacting the session before continuing.";
3
+ export const PIBO_CONTEXT_GUARD_RESUME_MESSAGE_TYPE = "pibo-context-guard-resume";
4
+ export const PIBO_CONTEXT_GUARD_RESUME_PROMPT = "Continue the interrupted task autonomously from the compacted context. Do not wait for additional user input and do not repeat the context-safety notice.";
3
5
  const DEFAULT_MIN_COMPACTION_RESERVE_TOKENS = 1024;
4
6
  const FALLBACK_CONTEXT_WINDOW = 0;
7
+ const MAX_CONTEXT_GUARD_RECOVERY_ATTEMPTS = 3;
8
+ const contextGuardRecoveries = new WeakMap();
9
+ export function createPiboAssistantContextGuardRecovery() {
10
+ let pending;
11
+ let claimed = false;
12
+ let cancelError;
13
+ let cancelHandler;
14
+ return {
15
+ begin() {
16
+ if (pending && !pending.settled)
17
+ return;
18
+ cancelError = undefined;
19
+ let resolve;
20
+ const promise = new Promise((done) => {
21
+ resolve = done;
22
+ });
23
+ pending = { promise, resolve, settled: false };
24
+ },
25
+ complete() {
26
+ if (!pending || pending.settled)
27
+ return;
28
+ pending.settled = true;
29
+ pending.resolve({ ok: true });
30
+ },
31
+ fail(error) {
32
+ if (!pending || pending.settled)
33
+ return;
34
+ pending.settled = true;
35
+ pending.resolve({ ok: false, error });
36
+ },
37
+ cancel(error) {
38
+ cancelError = error;
39
+ if (pending && !pending.settled) {
40
+ pending.settled = true;
41
+ pending.resolve({ ok: false, error });
42
+ }
43
+ cancelHandler?.();
44
+ },
45
+ claim() {
46
+ claimed = true;
47
+ },
48
+ isClaimed() {
49
+ return claimed;
50
+ },
51
+ isPending() {
52
+ return pending !== undefined && !pending.settled;
53
+ },
54
+ onCancel(handler) {
55
+ cancelHandler = handler;
56
+ },
57
+ async wait() {
58
+ const current = pending;
59
+ if (!current)
60
+ return false;
61
+ const outcome = await current.promise;
62
+ const cancelled = cancelError;
63
+ if (pending === current) {
64
+ pending = undefined;
65
+ cancelError = undefined;
66
+ }
67
+ if (cancelled)
68
+ throw cancelled;
69
+ if (!outcome.ok)
70
+ throw outcome.error;
71
+ return true;
72
+ },
73
+ };
74
+ }
75
+ export function registerPiboAssistantContextGuardRecovery(session, recovery) {
76
+ contextGuardRecoveries.set(session, recovery);
77
+ }
78
+ export function claimPiboAssistantContextGuardRecovery(session) {
79
+ contextGuardRecoveries.get(session)?.claim();
80
+ }
81
+ export function cancelPiboAssistantContextGuardRecovery(session, error) {
82
+ contextGuardRecoveries.get(session)?.cancel(error);
83
+ }
84
+ export function isPiboAssistantContextGuardRecoveryPending(session) {
85
+ return contextGuardRecoveries.get(session)?.isPending() ?? false;
86
+ }
87
+ export async function waitForPiboAssistantContextGuardRecovery(session) {
88
+ return await contextGuardRecoveries.get(session)?.wait() ?? false;
89
+ }
5
90
  function textTokensByLength(length) {
6
91
  return Math.ceil(length / 4);
7
92
  }
@@ -79,15 +164,43 @@ function compactionInstructions(projection) {
79
164
  : "";
80
165
  return `Pibo interrupted the previous assistant response before persisting the full output because it would exceed the safe context budget.${usage} Summarize the durable conversation up to the guard notice, preserve the user's current task and important recent facts, and leave enough context budget for the next response.`;
81
166
  }
82
- export function createPiboAssistantContextGuardExtension(options = {}) {
167
+ export function createPiboAssistantContextGuardExtension(options = {}, recovery = createPiboAssistantContextGuardRecovery()) {
83
168
  return (pi) => {
84
- const state = { deltaChars: 0, tripped: false, compactQueued: false };
169
+ const state = {
170
+ deltaChars: 0,
171
+ tripped: false,
172
+ compactQueued: false,
173
+ resumeRequested: false,
174
+ resumeInProgress: false,
175
+ resumeSettling: false,
176
+ recoveryAttempts: 0,
177
+ };
85
178
  function resetAssistantState() {
86
179
  state.deltaChars = 0;
87
180
  state.tripped = false;
88
181
  state.compactQueued = false;
89
182
  state.lastProjection = undefined;
90
183
  }
184
+ function clearResumeStartTimer() {
185
+ if (state.resumeStartTimer === undefined)
186
+ return;
187
+ clearTimeout(state.resumeStartTimer);
188
+ state.resumeStartTimer = undefined;
189
+ }
190
+ function resetRecoveryState() {
191
+ clearResumeStartTimer();
192
+ state.tripped = false;
193
+ state.compactQueued = false;
194
+ state.resumeRequested = false;
195
+ state.resumeInProgress = false;
196
+ state.resumeSettling = false;
197
+ state.recoveryAttempts = 0;
198
+ }
199
+ function failRecovery(error) {
200
+ resetRecoveryState();
201
+ recovery.fail(error);
202
+ }
203
+ recovery.onCancel(resetRecoveryState);
91
204
  function tripIfNeeded(ctx, assistantTokens) {
92
205
  const projection = projectAssistantContextGuard(ctx, assistantTokens, options);
93
206
  state.lastProjection = projection;
@@ -96,6 +209,20 @@ export function createPiboAssistantContextGuardExtension(options = {}) {
96
209
  state.tripped = true;
97
210
  return true;
98
211
  }
212
+ pi.on("agent_start", () => {
213
+ if (!state.resumeRequested)
214
+ return;
215
+ clearResumeStartTimer();
216
+ state.resumeRequested = false;
217
+ state.resumeInProgress = true;
218
+ });
219
+ pi.on("agent_settled", () => {
220
+ if (!state.resumeSettling)
221
+ return;
222
+ state.resumeSettling = false;
223
+ state.recoveryAttempts = 0;
224
+ recovery.complete();
225
+ });
99
226
  pi.on("message_start", (event) => {
100
227
  if (event.message.role === "assistant")
101
228
  resetAssistantState();
@@ -117,10 +244,50 @@ export function createPiboAssistantContextGuardExtension(options = {}) {
117
244
  return { message: replacementAssistantMessage(event.message) };
118
245
  });
119
246
  pi.on("agent_end", (_event, ctx) => {
120
- if (!state.tripped || state.compactQueued)
247
+ if (state.tripped && !state.compactQueued) {
248
+ if (state.recoveryAttempts >= MAX_CONTEXT_GUARD_RECOVERY_ATTEMPTS) {
249
+ failRecovery(new Error(`Context guard recovery exceeded ${MAX_CONTEXT_GUARD_RECOVERY_ATTEMPTS} compaction attempts`));
250
+ return;
251
+ }
252
+ state.compactQueued = true;
253
+ state.recoveryAttempts++;
254
+ recovery.begin();
255
+ let callbackHandled = false;
256
+ ctx.compact({
257
+ customInstructions: compactionInstructions(state.lastProjection),
258
+ onComplete: () => {
259
+ if (callbackHandled || !recovery.isPending())
260
+ return;
261
+ callbackHandled = true;
262
+ state.tripped = false;
263
+ state.compactQueued = false;
264
+ state.resumeRequested = true;
265
+ if (recovery.isClaimed()) {
266
+ recovery.complete();
267
+ return;
268
+ }
269
+ state.resumeStartTimer = setTimeout(() => {
270
+ failRecovery(new Error("Context guard continuation did not start"));
271
+ }, 5000);
272
+ pi.sendMessage({
273
+ customType: PIBO_CONTEXT_GUARD_RESUME_MESSAGE_TYPE,
274
+ content: [{ type: "text", text: PIBO_CONTEXT_GUARD_RESUME_PROMPT }],
275
+ display: false,
276
+ }, { triggerTurn: true });
277
+ },
278
+ onError: (error) => {
279
+ if (callbackHandled || !recovery.isPending())
280
+ return;
281
+ callbackHandled = true;
282
+ failRecovery(error);
283
+ },
284
+ });
285
+ return;
286
+ }
287
+ if (!state.resumeInProgress)
121
288
  return;
122
- state.compactQueued = true;
123
- ctx.compact({ customInstructions: compactionInstructions(state.lastProjection) });
289
+ state.resumeInProgress = false;
290
+ state.resumeSettling = true;
124
291
  });
125
292
  };
126
293
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { BestEffortTelemetryService, } from "../data/telemetry.js";
3
+ import { normalizeSessionErrorDetails } from "./session-errors.js";
3
4
  export class PiboProviderTelemetryRecorder {
4
5
  options;
5
6
  telemetry;
@@ -16,6 +17,9 @@ export class PiboProviderTelemetryRecorder {
16
17
  if (!turn)
17
18
  return undefined;
18
19
  const now = options.at ?? new Date().toISOString();
20
+ if (this.activeProviderRequest()) {
21
+ this.finishActiveProviderRequest("aborted", now, "provider request superseded", undefined, "provider_superseded");
22
+ }
19
23
  const model = modelInfo(options.model, this.options.model, payload);
20
24
  const providerRequestId = `pr_${randomUUID()}`;
21
25
  const phaseId = this.nextPhaseId(turn.turnId, "provider_request");
@@ -83,7 +87,7 @@ export class PiboProviderTelemetryRecorder {
83
87
  lastProgressAt: now,
84
88
  summary: "provider response received",
85
89
  });
86
- const streamPhaseId = `${request.turnId}:provider_stream:${request.providerRequestId}`;
90
+ const streamPhaseId = providerStreamPhaseId(request);
87
91
  this.telemetry.upsertPhase({
88
92
  phaseId: streamPhaseId,
89
93
  turnId: request.turnId,
@@ -122,6 +126,56 @@ export class PiboProviderTelemetryRecorder {
122
126
  return undefined;
123
127
  }
124
128
  }
129
+ // The assistant message boundary ends the provider stream even when the wider
130
+ // Pibo turn continues with a long-running tool or another provider request.
131
+ recordMessageEnd(message, options = {}) {
132
+ if (!this.options.store || !isAssistantMessage(message))
133
+ return undefined;
134
+ const status = providerStatusForMessage(message);
135
+ const summary = providerSummaryForStatus(status);
136
+ const errorMessage = safeMessageError(message);
137
+ const errorDetails = status === "error"
138
+ ? normalizeSessionErrorDetails(errorMessage ?? "Provider request failed.", {
139
+ api: optionalString(message.api),
140
+ provider: optionalString(message.provider),
141
+ model: optionalString(message.model),
142
+ })
143
+ : undefined;
144
+ return this.finishActiveProviderRequest(status, options.at ?? new Date().toISOString(), summary, errorMessage, errorDetails?.category ?? errorDetails?.errorClass);
145
+ }
146
+ recordShutdown(reason, at = new Date().toISOString()) {
147
+ return this.finishActiveProviderRequest("aborted", at, reason, undefined, "runtime_abort");
148
+ }
149
+ finishActiveProviderRequest(status, now, summary, errorMessage, errorCategory) {
150
+ if (!this.options.store)
151
+ return undefined;
152
+ try {
153
+ const request = this.activeProviderRequest();
154
+ if (!request)
155
+ return undefined;
156
+ const phaseStatus = status === "completed" ? "ok" : status;
157
+ const requestPhaseId = request.phaseId ?? `${request.turnId}:provider_request`;
158
+ if (this.options.store.getPhase(requestPhaseId)?.status === "open") {
159
+ this.telemetry.finishPhase(requestPhaseId, { status: phaseStatus, endedAt: now, lastProgressAt: now, summary });
160
+ }
161
+ const streamPhaseId = providerStreamPhaseId(request);
162
+ if (this.options.store.getPhase(streamPhaseId)?.status === "open") {
163
+ this.telemetry.finishPhase(streamPhaseId, { status: phaseStatus, endedAt: now, lastProgressAt: now, summary });
164
+ }
165
+ const updated = this.updateProviderRequest(request, {
166
+ status,
167
+ completedAt: now,
168
+ errorCategory: status === "error" ? errorCategory ?? "provider_error" : status === "aborted" ? errorCategory ?? "runtime_abort" : undefined,
169
+ errorMessage,
170
+ });
171
+ this.activeProviderRequestId = undefined;
172
+ return updated;
173
+ }
174
+ catch (error) {
175
+ this.options.onError?.(error);
176
+ return undefined;
177
+ }
178
+ }
125
179
  activeTurn() {
126
180
  const detail = this.options.store?.getSessionTelemetry(this.options.session.id, { limit: 10 });
127
181
  const active = detail?.activeTurn;
@@ -138,11 +192,10 @@ export class PiboProviderTelemetryRecorder {
138
192
  const turn = this.activeTurn();
139
193
  if (!turn)
140
194
  return undefined;
141
- const timeline = this.options.store?.getTurnTimeline(turn.turnId, { limit: 100 });
142
- return [...(timeline?.providerRequests ?? [])].reverse().find((request) => !isTerminalProviderStatus(request.status));
195
+ return this.options.store?.getActiveProviderRequestForTurn(turn.turnId);
143
196
  }
144
197
  nextPhaseId(turnId, phaseName) {
145
- const count = this.options.store?.getTurnTimeline(turnId, { limit: 100 })?.phases.filter((phase) => phase.name === phaseName).length ?? 0;
198
+ const count = this.options.store?.countPhasesForTurn(turnId, phaseName) ?? 0;
146
199
  return count === 0 ? `${turnId}:${phaseName}` : `${turnId}:${phaseName}:${count + 1}`;
147
200
  }
148
201
  updateProviderRequest(existing, input) {
@@ -162,12 +215,18 @@ export class PiboProviderTelemetryRecorder {
162
215
  responseHeadersAt: input.responseHeadersAt,
163
216
  firstByteAt: input.firstByteAt,
164
217
  httpStatus: input.httpStatus,
218
+ completedAt: input.completedAt,
165
219
  rawEventCount: existing.rawEventCount,
166
220
  normalizedEventCount: existing.normalizedEventCount,
167
221
  parseErrorCount: existing.parseErrorCount,
168
222
  unknownEventCount: existing.unknownEventCount,
169
223
  bytesReceived: existing.bytesReceived,
170
224
  eventTypeCounts: existing.eventTypeCounts,
225
+ eventStreamId: existing.eventStreamId,
226
+ eventId: existing.eventId,
227
+ payloadRef: existing.payloadRef,
228
+ errorCategory: input.errorCategory,
229
+ errorMessage: input.errorMessage,
171
230
  captureMode: existing.captureMode,
172
231
  retentionClass: existing.retentionClass,
173
232
  });
@@ -182,11 +241,45 @@ export function createPiboProviderTelemetryExtension(options) {
182
241
  pi.on("after_provider_response", (event) => {
183
242
  recorder.recordResponse({ status: event.status, headers: event.headers });
184
243
  });
244
+ pi.on("message_end", (event) => {
245
+ recorder.recordMessageEnd(event.message);
246
+ });
247
+ pi.on("session_shutdown", (event) => {
248
+ recorder.recordShutdown(`provider session shutdown: ${event.reason}`);
249
+ });
185
250
  };
186
251
  }
187
252
  export function isTerminalProviderStatus(status) {
188
253
  return status === "completed" || status === "error" || status === "aborted" || status === "timeout";
189
254
  }
255
+ function isAssistantMessage(message) {
256
+ return Boolean(message && typeof message === "object" && message.role === "assistant");
257
+ }
258
+ function providerStatusForMessage(message) {
259
+ if (message.stopReason === "error")
260
+ return "error";
261
+ if (message.stopReason === "aborted")
262
+ return "aborted";
263
+ return "completed";
264
+ }
265
+ function providerSummaryForStatus(status) {
266
+ if (status === "error")
267
+ return "provider stream failed";
268
+ if (status === "aborted")
269
+ return "provider stream aborted";
270
+ return "provider stream completed";
271
+ }
272
+ function providerStreamPhaseId(request) {
273
+ return `${request.turnId}:provider_stream:${request.providerRequestId}`;
274
+ }
275
+ function safeMessageError(message) {
276
+ return typeof message.errorMessage === "string" && message.errorMessage.trim().length > 0
277
+ ? message.errorMessage.replace(/\s+/g, " ").trim().slice(0, 512)
278
+ : undefined;
279
+ }
280
+ function optionalString(value) {
281
+ return typeof value === "string" && value.length > 0 ? value : undefined;
282
+ }
190
283
  function modelInfo(primary, fallback, payload) {
191
284
  const payloadModel = modelIdFromPayload(payload);
192
285
  return {
@@ -2,6 +2,7 @@ import { SessionManager, shouldCompact } from "@earendil-works/pi-coding-agent";
2
2
  import { getOpenAiCodexProviderUsageForActiveModel } from "../auth/openai-codex-usage.js";
3
3
  import { normalizeSessionErrorDetails, runtimeSessionErrorDetails } from "./session-errors.js";
4
4
  import { expandInlineSkills } from "./skill-expansion.js";
5
+ import { PIBO_CONTEXT_GUARD_RESUME_MESSAGE_TYPE, PIBO_CONTEXT_GUARD_RESUME_PROMPT, cancelPiboAssistantContextGuardRecovery, claimPiboAssistantContextGuardRecovery, waitForPiboAssistantContextGuardRecovery, } from "./context-guard.js";
5
6
  const FAST_SERVICE_TIER = "priority";
6
7
  function modelSupportsFastServiceTier(model) {
7
8
  if (!model)
@@ -21,6 +22,9 @@ function withFastServiceTierOption(options) {
21
22
  function errorMessage(error) {
22
23
  return error instanceof Error ? error.message : String(error);
23
24
  }
25
+ function isAssistantMessage(message) {
26
+ return Boolean(message && typeof message === "object" && message.role === "assistant");
27
+ }
24
28
  function numberValue(value) {
25
29
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
26
30
  }
@@ -388,6 +392,7 @@ export class RoutedSession {
388
392
  onSessionOperation;
389
393
  onKillChildren;
390
394
  onStateChange;
395
+ onMessagesInterrupted;
391
396
  queue = [];
392
397
  processing = false;
393
398
  disposed = false;
@@ -398,9 +403,12 @@ export class RoutedSession {
398
403
  nextAssistantIndex = 0;
399
404
  activeThinkingIndex;
400
405
  nextThinkingIndex = 0;
406
+ pendingAssistantError;
407
+ activeMessageFailed = false;
401
408
  unsubscribe;
409
+ recoverySession;
402
410
  isContinuePatched = false;
403
- constructor(piboSessionId, runtime, emit, pluginRegistry, forwardPiEvents, onPiEventTelemetry, initialFastMode, onSessionOperation, onKillChildren, onStateChange) {
411
+ constructor(piboSessionId, runtime, emit, pluginRegistry, forwardPiEvents, onPiEventTelemetry, initialFastMode, onSessionOperation, onKillChildren, onStateChange, onMessagesInterrupted) {
404
412
  this.piboSessionId = piboSessionId;
405
413
  this.runtime = runtime;
406
414
  this.emit = emit;
@@ -410,6 +418,7 @@ export class RoutedSession {
410
418
  this.onSessionOperation = onSessionOperation;
411
419
  this.onKillChildren = onKillChildren;
412
420
  this.onStateChange = onStateChange;
421
+ this.onMessagesInterrupted = onMessagesInterrupted;
413
422
  this.fastMode = initialFastMode && this.fastModeSupported();
414
423
  this.bindRuntimeSession();
415
424
  this.patchFastModeProviderRequest();
@@ -485,19 +494,44 @@ export class RoutedSession {
485
494
  }
486
495
  bindRuntimeSession() {
487
496
  this.unsubscribe?.();
488
- this.unsubscribe = this.runtime.session.subscribe((event) => {
497
+ const session = this.runtime.session;
498
+ if (this.recoverySession && this.recoverySession !== session) {
499
+ cancelPiboAssistantContextGuardRecovery(this.recoverySession, new Error("Context guard recovery cancelled because the Pi session changed"));
500
+ }
501
+ this.recoverySession = session;
502
+ claimPiboAssistantContextGuardRecovery(session);
503
+ this.unsubscribe = session.subscribe((event) => {
489
504
  this.onPiEventTelemetry?.(this.piboSessionId, event, { status: this.getStatus(), activeEventId: this.activeMessage?.id });
490
505
  const model = this.runtime.session.model;
491
506
  const normalized = normalizePiEvent(this.piboSessionId, event, { contextWindow: numberValue(model?.contextWindow) });
492
- if (normalized) {
493
- this.emit(this.withActiveMessage(normalized));
507
+ const candidate = event && typeof event === "object" ? event : undefined;
508
+ const assistantMessageEnded = candidate?.type === "message_end" && isAssistantMessage(candidate.message);
509
+ // Pi may recover an assistant error through retry or compaction. Publish it only
510
+ // after agent_settled confirms that no automatic continuation remains.
511
+ if (assistantMessageEnded && normalized?.type === "session_error") {
512
+ this.pendingAssistantError = this.withActiveMessage(normalized);
513
+ }
514
+ else {
515
+ if (assistantMessageEnded)
516
+ this.pendingAssistantError = undefined;
517
+ if (normalized)
518
+ this.emit(this.withActiveMessage(normalized));
494
519
  }
520
+ if (candidate?.type === "agent_settled")
521
+ this.flushPendingAssistantError();
495
522
  if (this.forwardPiEvents) {
496
523
  this.emit({ type: "pi_event", piboSessionId: this.piboSessionId, event });
497
524
  }
498
525
  this.handleCompactionEvent(event);
499
526
  });
500
527
  }
528
+ flushPendingAssistantError() {
529
+ if (!this.pendingAssistantError)
530
+ return;
531
+ this.activeMessageFailed = true;
532
+ this.emit(this.pendingAssistantError);
533
+ this.pendingAssistantError = undefined;
534
+ }
501
535
  handleCompactionEvent(event) {
502
536
  if (!event || typeof event !== "object")
503
537
  return;
@@ -565,6 +599,7 @@ export class RoutedSession {
565
599
  getStatus() {
566
600
  const enabledTools = this.runtime.session.getActiveToolNames();
567
601
  const thinkingLevel = this.runtime.session.thinkingLevel;
602
+ const settingsManager = this.runtime.session.settingsManager;
568
603
  return {
569
604
  piboSessionId: this.piboSessionId,
570
605
  queuedMessages: this.queue.length,
@@ -576,6 +611,12 @@ export class RoutedSession {
576
611
  disposed: this.disposed,
577
612
  thinkingLevel,
578
613
  fastMode: this.getFastModeResult().mode === "fast",
614
+ ...(settingsManager ? {
615
+ retry: {
616
+ ...settingsManager.getRetrySettings(),
617
+ provider: settingsManager.getProviderRetrySettings(),
618
+ },
619
+ } : {}),
579
620
  };
580
621
  }
581
622
  getContextUsage() {
@@ -595,15 +636,17 @@ export class RoutedSession {
595
636
  }
596
637
  removeQueuedMessages(predicate) {
597
638
  this.assertActive();
598
- let removed = 0;
639
+ const removedMessages = [];
599
640
  for (let index = this.queue.length - 1; index >= 0; index -= 1) {
600
641
  const item = this.queue[index];
601
642
  if (item.kind !== "message" || !predicate(item.event))
602
643
  continue;
603
644
  this.queue.splice(index, 1);
604
- removed += 1;
645
+ removedMessages.push(item.event);
605
646
  }
606
- return removed;
647
+ removedMessages.reverse();
648
+ this.notifyMessagesInterrupted(removedMessages, "queued message removed");
649
+ return removedMessages.length;
607
650
  }
608
651
  getCurrentSession() {
609
652
  return this.createSessionSnapshot();
@@ -726,16 +769,23 @@ export class RoutedSession {
726
769
  async dispose() {
727
770
  if (this.disposed)
728
771
  return;
772
+ this.notifyMessagesInterrupted(this.activeAndQueuedMessages(), "session disposed");
729
773
  this.queue.length = 0;
730
774
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: true });
731
775
  this.unsubscribe?.();
732
776
  this.unsubscribe = undefined;
777
+ if (this.recoverySession) {
778
+ this.cancelContextGuardRecovery("Context guard recovery cancelled because the routed session was disposed");
779
+ this.recoverySession = undefined;
780
+ }
733
781
  this.disposed = true;
734
782
  await this.runtime.dispose();
735
783
  }
736
784
  async kill() {
785
+ this.notifyMessagesInterrupted(this.activeAndQueuedMessages(), "session killed");
737
786
  this.queue.length = 0;
738
787
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: this.disposed });
788
+ this.cancelContextGuardRecovery("Context guard recovery cancelled because the routed session was killed");
739
789
  await this.runtime.session.abort();
740
790
  return this.piboSessionId;
741
791
  }
@@ -743,11 +793,15 @@ export class RoutedSession {
743
793
  this.assertActive();
744
794
  const queuedIndex = this.queue.findIndex((item) => item.event.id === eventId);
745
795
  if (queuedIndex >= 0) {
746
- this.queue.splice(queuedIndex, 1);
796
+ const [removed] = this.queue.splice(queuedIndex, 1);
797
+ if (removed?.kind === "message")
798
+ this.notifyMessagesInterrupted([removed.event], "message cancelled");
747
799
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: this.disposed });
748
800
  return true;
749
801
  }
750
802
  if (this.activeMessage?.id === eventId) {
803
+ this.notifyMessagesInterrupted([this.activeMessage], "message cancelled");
804
+ this.cancelContextGuardRecovery("Context guard recovery cancelled with the active message");
751
805
  await this.runtime.session.abort();
752
806
  return true;
753
807
  }
@@ -785,18 +839,37 @@ export class RoutedSession {
785
839
  });
786
840
  try {
787
841
  this.activeMessage = event;
842
+ this.pendingAssistantError = undefined;
843
+ this.activeMessageFailed = false;
788
844
  this.activeAssistantIndex = undefined;
789
845
  this.nextAssistantIndex = 0;
790
846
  this.activeThinkingIndex = undefined;
791
847
  this.nextThinkingIndex = 0;
792
- const expandedText = expandInlineSkills(event.text, this.runtime.session.resourceLoader.getSkills().skills);
793
- await this.runtime.session.prompt(expandedText, { source: promptSource(event.source) });
794
- this.emit({
795
- type: "message_finished",
796
- piboSessionId: this.piboSessionId,
797
- eventId: event.id,
798
- source: event.source,
799
- });
848
+ const session = this.runtime.session;
849
+ const expandedText = expandInlineSkills(event.text, session.resourceLoader.getSkills().skills);
850
+ await session.prompt(expandedText, { source: promptSource(event.source) });
851
+ while (await waitForPiboAssistantContextGuardRecovery(session)) {
852
+ try {
853
+ await session.sendCustomMessage({
854
+ customType: PIBO_CONTEXT_GUARD_RESUME_MESSAGE_TYPE,
855
+ content: [{ type: "text", text: PIBO_CONTEXT_GUARD_RESUME_PROMPT }],
856
+ display: false,
857
+ }, { triggerTurn: true });
858
+ }
859
+ catch (error) {
860
+ const resumeError = error instanceof Error ? error : new Error(String(error));
861
+ cancelPiboAssistantContextGuardRecovery(session, resumeError);
862
+ throw resumeError;
863
+ }
864
+ }
865
+ if (!this.activeMessageFailed) {
866
+ this.emit({
867
+ type: "message_finished",
868
+ piboSessionId: this.piboSessionId,
869
+ eventId: event.id,
870
+ source: event.source,
871
+ });
872
+ }
800
873
  }
801
874
  catch (error) {
802
875
  const message = errorMessage(error);
@@ -810,6 +883,8 @@ export class RoutedSession {
810
883
  }
811
884
  finally {
812
885
  this.activeMessage = undefined;
886
+ this.pendingAssistantError = undefined;
887
+ this.activeMessageFailed = false;
813
888
  this.activeAssistantIndex = undefined;
814
889
  this.nextAssistantIndex = 0;
815
890
  this.activeThinkingIndex = undefined;
@@ -866,6 +941,9 @@ export class RoutedSession {
866
941
  getProviderUsage: () => this.getProviderUsage(),
867
942
  clearQueue: () => this.clearQueue(),
868
943
  abort: async () => {
944
+ if (this.activeMessage)
945
+ this.notifyMessagesInterrupted([this.activeMessage], "abort requested");
946
+ this.cancelContextGuardRecovery("Context guard recovery cancelled by abort");
869
947
  await this.runtime.session.abort();
870
948
  },
871
949
  dispose: () => this.dispose(),
@@ -906,6 +984,11 @@ export class RoutedSession {
906
984
  },
907
985
  }, event);
908
986
  }
987
+ cancelContextGuardRecovery(message) {
988
+ const session = this.recoverySession ?? this.runtime.session;
989
+ cancelPiboAssistantContextGuardRecovery(session, new Error(message));
990
+ session.abortCompaction?.();
991
+ }
909
992
  assertActive() {
910
993
  if (this.disposed) {
911
994
  throw new Error(`Session "${this.piboSessionId}" has been disposed`);
@@ -927,10 +1010,21 @@ export class RoutedSession {
927
1010
  }
928
1011
  clearQueue() {
929
1012
  const cleared = this.queue.length;
1013
+ const removedMessages = this.queue.flatMap((item) => item.kind === "message" ? [item.event] : []);
930
1014
  this.queue.length = 0;
1015
+ this.notifyMessagesInterrupted(removedMessages, "queue cleared");
931
1016
  this.onStateChange?.({ processing: this.processing, queuedMessages: this.queue.length, disposed: this.disposed });
932
1017
  return cleared;
933
1018
  }
1019
+ activeAndQueuedMessages() {
1020
+ const messages = this.queue.flatMap((item) => item.kind === "message" ? [item.event] : []);
1021
+ return this.activeMessage ? [this.activeMessage, ...messages] : messages;
1022
+ }
1023
+ notifyMessagesInterrupted(messages, reason) {
1024
+ if (messages.length === 0)
1025
+ return;
1026
+ this.onMessagesInterrupted?.(messages, reason);
1027
+ }
934
1028
  createSessionSnapshot() {
935
1029
  const session = this.runtime.session;
936
1030
  const manager = session.sessionManager;