@matthewfl/pi-contemplator 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
5
5
  import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Text } from "@earendil-works/pi-tui";
8
- import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
8
+ import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
9
9
  import { hashId } from "../../ids.js";
10
10
  import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
11
11
  import { createRecallAgentTool } from "../../tools/recall-observation.js";
@@ -17,6 +17,7 @@ import { forceRequiredToolPayload, requiredToolChoice } from "../../required-too
17
17
  import { memoryReferenceIds } from "../../memory-citations.js";
18
18
  import { buildContemplatorSystemPrompt } from "./prompts.js";
19
19
  import { runStructuralReview } from "../reviewer/agent.js";
20
+ import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
20
21
 
21
22
  interface PendingUpdate {
22
23
  observations: string[];
@@ -213,6 +214,10 @@ export class Contemplator {
213
214
  private history: AgentMessage[] = [];
214
215
  private pending: PendingUpdate | undefined;
215
216
  private running = false;
217
+ /** Invalidates stale/hard-timed-out flush finalizers across session changes. */
218
+ private flushEpoch = 0;
219
+ /** Bounds retries of one poisoned memory update so future updates can run. */
220
+ private consecutiveFlushFailures = 0;
216
221
  private seenObservationIds = new Set<string>();
217
222
  private seenSummaryIds = new Set<string>();
218
223
  private seenReviewIds = new Set<string>();
@@ -263,6 +268,9 @@ export class Contemplator {
263
268
  });
264
269
  this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
265
270
  this.sessionGeneration++;
271
+ this.flushEpoch++;
272
+ this.running = false;
273
+ this.consecutiveFlushFailures = 0;
266
274
  const generation = this.sessionGeneration;
267
275
  this.agentActiveSince = undefined;
268
276
  // AgentSession preserves its steering queue across extension reloads. An
@@ -278,12 +286,18 @@ export class Contemplator {
278
286
  });
279
287
  this.pi.on("session_tree", (_event: any, ctx: ExtensionContext) => {
280
288
  this.sessionGeneration++;
289
+ this.flushEpoch++;
290
+ this.running = false;
291
+ this.consecutiveFlushFailures = 0;
281
292
  this.agentActiveSince = undefined;
282
293
  // Pending steering messages remain queued while navigating the tree.
283
294
  this.restore(ctx, true, true);
284
295
  });
285
296
  this.pi.on("session_shutdown", () => {
286
297
  this.sessionGeneration++;
298
+ this.flushEpoch++;
299
+ this.running = false;
300
+ this.consecutiveFlushFailures = 0;
287
301
  this.agentActiveSince = undefined;
288
302
  this.history = [];
289
303
  this.pending = undefined;
@@ -555,6 +569,23 @@ export class Contemplator {
555
569
  return;
556
570
  }
557
571
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
572
+ const observerBacklogTokens = rawTokensSinceObservationCoverage(branchEntries);
573
+ if (
574
+ observerBacklogTokens >= this.runtime.config.observeAfterTokens &&
575
+ (this.runtime.consolidationInFlight || this.runtime.lastObserverError === undefined)
576
+ ) {
577
+ // A catch-up observer pipeline may append several partial batches while it
578
+ // drains old source. Do not let primary-agent checkpoints feed those
579
+ // fragments to the contemplator one at a time. The pipeline emits one
580
+ // memory update after the backlog falls below the observer trigger.
581
+ this.publishState("observer");
582
+ debugLog("contemplator.waiting", {
583
+ reason: "observer_backlog",
584
+ observerBacklogTokens,
585
+ observeAfterTokens: this.runtime.config.observeAfterTokens,
586
+ });
587
+ return;
588
+ }
558
589
  const projection = fullProjection(branchEntries);
559
590
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
560
591
  const summaries = projection.summaries.map((item) => `[${item.id}] ${item.content}`);
@@ -637,6 +668,7 @@ export class Contemplator {
637
668
  this.pending = undefined;
638
669
  const turnsBeforeRun = this.turnsSinceRun;
639
670
  const sessionGeneration = this.sessionGeneration;
671
+ const flushEpoch = ++this.flushEpoch;
640
672
  this.running = true;
641
673
  this.turnsSinceRun = 0;
642
674
  const startedAt = Date.now();
@@ -644,6 +676,7 @@ export class Contemplator {
644
676
  let failureMessage: string | undefined;
645
677
  let workerNotified = false;
646
678
  let promptPersisted = false;
679
+ let workerWatchdog: ReturnType<typeof createWorkerStallWatchdog> | undefined;
647
680
  this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
648
681
  debugLog("contemplator.start", {
649
682
  newObservationCount: update.observations.length,
@@ -664,16 +697,22 @@ export class Contemplator {
664
697
  failureMessage = resolved.reason;
665
698
  debugLog("contemplator.model_unavailable", { reason: resolved.reason });
666
699
  if (sessionGeneration === this.sessionGeneration) {
667
- const pending = this.pending as PendingUpdate | undefined;
668
- this.pending = {
669
- observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
670
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
671
- reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
672
- mainAgentOutputTokens: update.mainAgentOutputTokens,
673
- mainAgentToolCalls: update.mainAgentToolCalls,
674
- mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
675
- };
676
- this.turnsSinceRun = turnsBeforeRun;
700
+ this.consecutiveFlushFailures++;
701
+ if (this.consecutiveFlushFailures < 2) {
702
+ const pending = this.pending as PendingUpdate | undefined;
703
+ this.pending = {
704
+ observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
705
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
706
+ reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
707
+ mainAgentOutputTokens: update.mainAgentOutputTokens,
708
+ mainAgentToolCalls: update.mainAgentToolCalls,
709
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
710
+ };
711
+ } else {
712
+ debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
713
+ this.consecutiveFlushFailures = 0;
714
+ }
715
+ this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
677
716
  }
678
717
  return;
679
718
  }
@@ -687,6 +726,7 @@ export class Contemplator {
687
726
  modelId: selectedModel.id,
688
727
  contextWindow: selectedModel.contextWindow,
689
728
  });
729
+ workerWatchdog = createWorkerStallWatchdog("contemplator");
690
730
  if (this.runtime.config.showWorkerNotifications && ctx.hasUI) {
691
731
  ctx.ui?.notify("pi-contemplator: contemplator running", "info");
692
732
  workerNotified = true;
@@ -774,9 +814,15 @@ export class Contemplator {
774
814
  // while individual provider APIs also support required/any. Preserve the
775
815
  // runtime hint and final-payload enforcement without weakening base types.
776
816
  if (invocation > 1) (invocationConfig as any).toolChoice = requiredToolChoice(api);
777
- const stream = agentLoop([nextPrompt], context, invocationConfig, undefined, streamSimple);
778
- for await (const event of stream) logAgentStreamError("contemplator", event);
779
- const result = await stream.result();
817
+ workerWatchdog.progress();
818
+ const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal, streamSimple);
819
+ const result = await workerWatchdog.race((async () => {
820
+ for await (const event of stream) {
821
+ workerWatchdog!.progress();
822
+ logAgentStreamError("contemplator", event);
823
+ }
824
+ return stream.result();
825
+ })());
780
826
  // agentLoop returns its input prompt as the first new message. We already
781
827
  // added nextPrompt above, so do not duplicate each update in the durable
782
828
  // contemplator history or in a subsequent retry's context.
@@ -840,25 +886,37 @@ export class Contemplator {
840
886
  });
841
887
  }
842
888
  }
843
- if (sessionGeneration === this.sessionGeneration) await this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration);
889
+ if (sessionGeneration === this.sessionGeneration) {
890
+ workerWatchdog.progress();
891
+ await workerWatchdog.race(this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch));
892
+ }
844
893
  } catch (error) {
845
894
  failed = true;
846
895
  failureMessage = error instanceof Error ? error.message : String(error);
847
896
  debugLog("contemplator.error", { errorMessage: failureMessage });
848
897
  if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
849
- const pending = this.pending as PendingUpdate | undefined;
850
- this.pending = {
851
- observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
852
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
853
- reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
854
- mainAgentOutputTokens: update.mainAgentOutputTokens,
855
- mainAgentToolCalls: update.mainAgentToolCalls,
856
- mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
857
- };
858
- this.turnsSinceRun = turnsBeforeRun;
898
+ this.consecutiveFlushFailures++;
899
+ if (this.consecutiveFlushFailures < 2) {
900
+ const pending = this.pending as PendingUpdate | undefined;
901
+ this.pending = {
902
+ observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
903
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
904
+ reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
905
+ mainAgentOutputTokens: update.mainAgentOutputTokens,
906
+ mainAgentToolCalls: update.mainAgentToolCalls,
907
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
908
+ };
909
+ } else {
910
+ debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
911
+ this.consecutiveFlushFailures = 0;
912
+ }
913
+ this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
859
914
  }
860
915
  } finally {
916
+ workerWatchdog?.dispose();
917
+ if (flushEpoch !== this.flushEpoch) return;
861
918
  this.running = false;
919
+ if (!failed) this.consecutiveFlushFailures = 0;
862
920
  const pendingHasEnoughMemories = this.pending !== undefined && (
863
921
  this.pending.reviews.length > 0 ||
864
922
  this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
@@ -951,15 +1009,20 @@ export class Contemplator {
951
1009
  const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [], messageEntryIds: [], foldedEntryIds: new Set() };
952
1010
  this.reviewerSessions.set(request.id, session);
953
1011
  const task = this.runtime.launchReviewTask(ctx, async () => {
1012
+ const watchdog = createWorkerStallWatchdog("structural reviewer");
1013
+ let acceptsMessages = true;
954
1014
  try {
955
1015
  debugLog("reviewer.started", { reviewRequestId: request.id, scope: request.scope, resumed: session.history.length > 0 });
956
- const result = await runStructuralReview({
1016
+ const result = await watchdog.race(runStructuralReview({
957
1017
  request, model, apiKey, headers,
1018
+ signal: watchdog.signal,
1019
+ onProgress: watchdog.progress,
958
1020
  getBranch: () => ctx.sessionManager.getBranch() as Entry[],
959
1021
  recordUsage: (usage) => this.runtime.recordAgentUsage(usage),
960
1022
  history: session.history,
961
1023
  onMessages: (messages) => {
962
- if (sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
1024
+ watchdog.progress();
1025
+ if (!acceptsMessages || sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
963
1026
  for (const message of messages) {
964
1027
  session.history.push(message);
965
1028
  const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
@@ -969,7 +1032,7 @@ export class Contemplator {
969
1032
  }
970
1033
  }
971
1034
  },
972
- });
1035
+ }));
973
1036
  if (sessionGeneration !== this.sessionGeneration) {
974
1037
  debugLog("reviewer.failed", { reviewRequestId: request.id, reason: "session_changed" });
975
1038
  return;
@@ -997,6 +1060,8 @@ export class Contemplator {
997
1060
  }
998
1061
  this.runtime.notifyMemoryUpdate(ctx);
999
1062
  } finally {
1063
+ acceptsMessages = false;
1064
+ watchdog.dispose();
1000
1065
  this.inFlightReviewIds.delete(request.id);
1001
1066
  if (key) this.inFlightReviewKeys.delete(key);
1002
1067
  }
@@ -1080,7 +1145,7 @@ export class Contemplator {
1080
1145
  return own?.id;
1081
1146
  }
1082
1147
 
1083
- private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number): Promise<void> {
1148
+ private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
1084
1149
  const serializedLength = this.history.reduce((total, message) => total + JSON.stringify(message).length, 0);
1085
1150
  if (this.history.length < 12 || serializedLength < 60_000) return;
1086
1151
  const previousMessageCount = this.history.length;
@@ -1091,7 +1156,7 @@ export class Contemplator {
1091
1156
  const history = this.history.slice();
1092
1157
  const summaryWithUsage = await generateSummaryWithUsage(history as AgentMessage[], model, 4_000, apiKey, headers);
1093
1158
  this.runtime.recordAgentUsage(summaryWithUsage.usage);
1094
- if (sessionGeneration !== this.sessionGeneration) {
1159
+ if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
1095
1160
  debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
1096
1161
  return;
1097
1162
  }
@@ -1,11 +1,11 @@
1
- import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
5
5
  import type { Static } from "typebox";
6
6
  import { hashId } from "../../ids.js";
7
7
  import { logAgentStreamError } from "../stream-errors.js";
8
- import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
8
+ import { OBSERVER_AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
9
9
  import { OBSERVER_SYSTEM } from "./prompts.js";
10
10
  import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
11
11
  import type { Observation, Relevance, Retention } from "../../session-ledger/index.js";
@@ -25,6 +25,7 @@ interface RunObserverArgs {
25
25
  maxTurns?: number;
26
26
  thinkingLevel?: ModelThinkingLevel;
27
27
  recordUsage?: (usage: LlmUsageInput) => void;
28
+ onProgress?: () => void;
28
29
  }
29
30
 
30
31
  const RelevanceSchema = Type.Union([
@@ -71,6 +72,16 @@ const RecordObservationsSchema = Type.Object({
71
72
 
72
73
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
73
74
 
75
+ /** A terminal provider/agent-loop failure that must not advance observation coverage. */
76
+ export class ObserverStreamError extends Error {
77
+ readonly stopReason: string;
78
+ constructor(stopReason: string, errorMessage?: string) {
79
+ super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
80
+ this.name = "ObserverStreamError";
81
+ this.stopReason = stopReason;
82
+ }
83
+ }
84
+
74
85
  function joinOrEmpty(items: string[]): string {
75
86
  return items.length ? items.join("\n") : "(none yet)";
76
87
  }
@@ -98,14 +109,15 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
98
109
  if (!conversation) return undefined;
99
110
 
100
111
  const accumulated = new Map<string, Observation>();
112
+ let rejectedTotal = 0;
113
+ let doneCalled = false;
101
114
 
102
115
  const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
103
116
  name: "record_observations",
104
117
  label: "Record observations",
105
118
  description:
106
119
  "Record a batch of new observations distilled from the conversation chunk. " +
107
- "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
108
- "then emit a short plain-text confirmation to end the run.",
120
+ "Call this multiple times as you work through the chunk, then call done alone when coverage is complete.",
109
121
  parameters: RecordObservationsSchema,
110
122
  execute: async (_id, params: RecordObservationsArgs) => {
111
123
  let added = 0;
@@ -134,6 +146,7 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
134
146
  });
135
147
  added++;
136
148
  }
149
+ rejectedTotal += rejected;
137
150
  const rejectedPart = rejected > 0
138
151
  ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
139
152
  : "";
@@ -142,11 +155,22 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
142
155
  (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
143
156
  rejectedPart +
144
157
  ` Total so far this run: ${accumulated.size}. ` +
145
- `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
158
+ `Continue if the chunk still has uncovered content; otherwise call done alone.`;
146
159
  return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
147
160
  },
148
161
  };
149
162
 
163
+ const doneTool: AgentTool<any> = {
164
+ name: "done",
165
+ label: "Done",
166
+ description: "Confirm that the entire provided conversation chunk has been inspected and all useful new observations have been recorded. Call alone, including when there is nothing new to record.",
167
+ parameters: Type.Object({}),
168
+ execute: async () => {
169
+ doneCalled = true;
170
+ return { content: [{ type: "text", text: "Observer coverage confirmed." }], details: {}, terminate: true };
171
+ },
172
+ };
173
+
150
174
  const now = nowTimestamp();
151
175
  const userText = `Current local time: ${now}
152
176
 
@@ -156,60 +180,88 @@ ${joinOrEmpty(priorSummaries)}
156
180
  CURRENT OBSERVATIONS:
157
181
  ${joinOrEmpty(priorObservations)}
158
182
 
159
- Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. Stop calling the tool and reply with a short plain-text confirmation once the chunk is fully covered.
183
+ Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
160
184
 
161
185
  NEW CONVERSATION CHUNK:
162
- ${conversation}`;
186
+ ${conversation}
163
187
 
164
- const prompts: Message[] = [
165
- {
166
- role: "user",
167
- content: [{ type: "text", text: userText }],
168
- timestamp: Date.now(),
169
- },
170
- ];
188
+ END NEW CONVERSATION CHUNK
171
189
 
172
- const context: AgentContext = {
173
- systemPrompt: OBSERVER_SYSTEM,
174
- messages: [],
175
- tools: [recordObservations as AgentTool<any>],
190
+ IMPORTANT: Now call record_observations to record the useful new observations from this conversation chunk. When the chunk is covered, call done; if there are no useful observations, call done without recording any.`;
191
+
192
+ const initialPrompt: Message = {
193
+ role: "user",
194
+ content: [{ type: "text", text: userText }],
195
+ timestamp: Date.now(),
176
196
  };
177
197
 
178
198
  const reasoning = (model as { reasoning?: unknown }).reasoning;
179
199
  const thinkingLevel = args.thinkingLevel ?? "low";
180
200
  const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
181
201
  let turnCount = 0;
182
- const config: AgentLoopConfig = {
202
+ const baseConfig: AgentLoopConfig = {
183
203
  model,
184
204
  apiKey,
185
205
  headers,
186
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
206
+ maxTokens: boundedMaxTokens(model, OBSERVER_AGENT_LOOP_MAX_TOKENS),
187
207
  convertToLlm: (msgs) => msgs as Message[],
188
208
  toolExecution: "sequential",
209
+ shouldStopAfterTurn: () => {
210
+ turnCount++;
211
+ return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns);
212
+ },
189
213
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
190
- ...(effectiveMaxTurns !== undefined
191
- ? {
192
- shouldStopAfterTurn: () => {
193
- turnCount++;
194
- return turnCount >= effectiveMaxTurns;
195
- },
196
- }
197
- : {}),
198
214
  };
199
215
 
200
216
  const loop = args.agentLoop ?? agentLoop;
201
- const stream = loop(prompts, context, config, signal, streamSimple);
202
- for await (const event of stream) {
203
- // Drain events; the tool's execute already collects records.
204
- logAgentStreamError("observer", event);
205
- }
206
- const result = await stream.result();
207
- if (args.recordUsage) {
217
+ const history: AgentMessage[] = [];
218
+ let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
219
+ const runInvocation = async (prompt: Message): Promise<void> => {
220
+ const context: AgentContext = {
221
+ systemPrompt: OBSERVER_SYSTEM,
222
+ messages: history.slice(),
223
+ tools: [recordObservations as AgentTool<any>, doneTool],
224
+ };
225
+ const stream = loop([prompt], context, baseConfig, signal, streamSimple);
226
+ for await (const event of stream) {
227
+ args.onProgress?.();
228
+ logAgentStreamError("observer", event);
229
+ const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
230
+ if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
231
+ terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
232
+ }
233
+ }
234
+ const result = await stream.result();
235
+ if (!Array.isArray(result)) return;
236
+ history.push(...result);
208
237
  for (const message of result) {
209
- if (message.role === "assistant" && message.usage) args.recordUsage(message.usage);
238
+ if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
239
+ terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
240
+ }
241
+ if (args.recordUsage && message.role === "assistant" && message.usage) args.recordUsage(message.usage);
210
242
  }
243
+ };
244
+
245
+ await runInvocation(initialPrompt);
246
+ if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
247
+ const reminder: Message = {
248
+ role: "user",
249
+ content: [{ type: "text", text: `You stopped without confirming coverage. Observations recorded so far: ${accumulated.size}. If the chunk is fully covered, call done now. Otherwise call record_observations for anything still missing, then call done.` }],
250
+ timestamp: Date.now(),
251
+ };
252
+ await runInvocation(reminder);
211
253
  }
212
254
 
255
+ // `done` is a behavioral aid and terminal shortcut, not a transaction gate.
256
+ // Accepted observations commit even if it was omitted. A second prose-only
257
+ // zero-observation stop is also a valid empty result after the reminder;
258
+ // actual stream failures, truncation, and malformed records still throw.
259
+ if (accumulated.size === 0 && terminalFailure) {
260
+ throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
261
+ }
262
+ if (accumulated.size === 0 && rejectedTotal > 0) {
263
+ throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
264
+ }
213
265
  if (accumulated.size === 0) return undefined;
214
266
  return Array.from(accumulated.values());
215
267
  }
@@ -15,7 +15,7 @@ How you work:
15
15
  2. Read the conversation chunk and identify what new information it contains.
16
16
  3. Call record_observations with a batch covering part (or all) of the chunk.
17
17
  4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
18
- 5. When the chunk is fully covered, STOP calling the tool and reply with a brief plain-text confirmation (one short sentence). That ends the run.
18
+ 5. When the chunk is fully covered, call done alone. If there is no useful new information, call done without calling record_observations. Prose does not confirm coverage.
19
19
 
20
20
  What to emit:
21
21
  - Produce NEW observations for the new chunk only. Do not restate facts already present in summaries or current observations unless something has materially changed.
@@ -25,7 +25,7 @@ What to emit:
25
25
  - For every observation, choose retention independently from relevance. Recording the observation correctly comes first; never skip useful evidence because retention is uncertain.
26
26
  - Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
27
27
  - Group repeated similar tool calls into a single observation rather than one per call.
28
- - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, simply do not call the tool and end with a plain-text confirmation.
28
+ - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, do not call record_observations and call done alone. Ignoring a chunk or replying in prose does not mark it covered.
29
29
 
30
30
  Observation content rules:
31
31
 
@@ -125,4 +125,4 @@ A critical exact blocker can be contextual; a medium stable preference can be du
125
125
 
126
126
  Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
127
127
 
128
- Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
128
+ Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count. Always finish by calling done alone.`;
@@ -30,6 +30,7 @@ export interface RunStructuralReviewArgs {
30
30
  recordUsage?: (usage: LlmUsageInput) => void;
31
31
  /** Receives the reviewer's assistant output for durable debug/view rendering. */
32
32
  onMessages?: (messages: AgentMessage[]) => void;
33
+ onProgress?: () => void;
33
34
  /** Previously persisted reviewer transcript; a non-empty history resumes work. */
34
35
  history?: AgentMessage[];
35
36
  }
@@ -177,7 +178,10 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
177
178
  history.push(promptMessage);
178
179
  args.onMessages?.([promptMessage]);
179
180
  const stream = loop([prompt], context, config, args.signal, budgetedStreamSimple);
180
- for await (const event of stream) logAgentStreamError("reviewer", event);
181
+ for await (const event of stream) {
182
+ args.onProgress?.();
183
+ logAgentStreamError("reviewer", event);
184
+ }
181
185
  const newMessages = await stream.result();
182
186
  history.push(...newMessages);
183
187
  args.onMessages?.(newMessages);
@@ -63,11 +63,13 @@ function liveStateLine(state: ContemplatorRunState): string {
63
63
  const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last end: ${state.lastCompletedAt === undefined ? "not completed this launch" : new Date(state.lastCompletedAt).toISOString()}`;
64
64
  const error = state.lastError ? `\nLast error: ${state.lastError}` : "";
65
65
  if (state.running) return `LIVE · running for ${Math.max(0, Math.floor((Date.now() - (state.lastStartedAt ?? Date.now())) / 60_000))}m · ${pending}\n${timing}${error}`;
66
- const reason = state.waitingFor === "memories"
67
- ? "waiting for memory threshold"
68
- : state.waitingFor === "responses"
69
- ? "waiting for response spacing"
70
- : state.waitingFor === "ready"
66
+ const reason = state.waitingFor === "observer"
67
+ ? "waiting for observer backlog"
68
+ : state.waitingFor === "memories"
69
+ ? "waiting for memory threshold"
70
+ : state.waitingFor === "responses"
71
+ ? "waiting for response spacing"
72
+ : state.waitingFor === "ready"
71
73
  ? "ready to launch"
72
74
  : state.waitingFor === "disabled"
73
75
  ? "disabled"
@@ -1,13 +1,14 @@
1
1
  import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, getKeybindings, Input, SelectList, Spacer, Text, fuzzyFilter, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
4
- import type { ConfiguredModel } from "../config.js";
4
+ import { OBSERVER_CHUNK_CONTEXT_RATIO, resolveObserverChunkMaxTokens, type ConfiguredModel } from "../config.js";
5
5
  import { OM_SETTINGS, type Runtime, type SessionSettings } from "../runtime.js";
6
6
 
7
7
  type ModelRegistryLike = {
8
8
  refresh?(): Promise<void>;
9
9
  getAvailable(): Array<{ provider: string; id: string }>;
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
+ find?(provider: string, id: string): { contextWindow?: number } | undefined;
11
12
  };
12
13
  type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
13
14
  type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
@@ -44,6 +45,26 @@ function extensionEnabledLabel(runtime: Runtime): string {
44
45
  return hasOverride(runtime.getSessionSettings(), "passive") ? String(enabled) : `${enabled} (default)`;
45
46
  }
46
47
 
48
+ export function observerInputCapLabel(runtime: Runtime, contextWindow: number | undefined): string {
49
+ const explicit = runtime.config.observerChunkMaxTokens;
50
+ if (explicit !== undefined) {
51
+ return `${explicit.toLocaleString()} tokens${hasOverride(runtime.getSessionSettings(), "observerChunkMaxTokens") ? "" : " (default)"}`;
52
+ }
53
+ const cap = resolveObserverChunkMaxTokens(runtime.config, contextWindow);
54
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
55
+ const percent = OBSERVER_CHUNK_CONTEXT_RATIO * 100;
56
+ return `${percent}% of ${contextWindow.toLocaleString()} = ${cap.toLocaleString()} tokens (derived default)`;
57
+ }
58
+ return `${cap.toLocaleString()} tokens (fallback default; model context unavailable)`;
59
+ }
60
+
61
+ function observerContextWindow(runtime: Runtime, ctx: ExtensionContext): number | undefined {
62
+ const configured = runtime.config.model;
63
+ const registry = ctx.modelRegistry as unknown as ModelRegistryLike;
64
+ const model = configured ? registry.find?.(configured.provider, configured.id) ?? ctx.model : ctx.model;
65
+ return (model as { contextWindow?: number } | undefined)?.contextWindow;
66
+ }
67
+
47
68
  interface ModelOption extends SelectItem {
48
69
  configuredModel: ConfiguredModel | null;
49
70
  }
@@ -207,7 +228,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
207
228
  `Observe source during compaction: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
208
229
  `Observer and summarizer model: ${hasOverride(settings, "model") ? modelLabel(runtime.config.model) : `${modelLabel(runtime.getDefaultConfig().model)} (default)`}`,
209
230
  `Observer source backlog trigger (tokens): ${scalarLabel(runtime, "observeAfterTokens")}`,
210
- `Observer input cap (tokens): ${scalarLabel(runtime, "observerChunkMaxTokens")}`,
231
+ `Observer input cap: ${observerInputCapLabel(runtime, observerContextWindow(runtime, ctx))}`,
211
232
  `Observer and summarizer max rounds: ${scalarLabel(runtime, "agentMaxTurns")}`,
212
233
  `Automatic compaction source backlog trigger (tokens): ${scalarLabel(runtime, "compactAfterTokens")}`,
213
234
  `Automatic compaction threshold mode: ${hasOverride(settings, "compactAfterTokensMode") ? runtime.config.compactAfterTokensMode : `${runtime.getDefaultConfig().compactAfterTokensMode} (default)`}`,
@@ -245,7 +266,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
245
266
  } else {
246
267
  const numberChoice: Array<[string, NumberSetting, string]> = [
247
268
  ["Observer source backlog trigger (tokens):", "observeAfterTokens", "Observer source backlog trigger (tokens)"],
248
- ["Observer input cap (tokens):", "observerChunkMaxTokens", "Observer input cap (tokens)"],
269
+ ["Observer input cap:", "observerChunkMaxTokens", "Observer input cap (tokens)"],
249
270
  ["Observer and summarizer max rounds:", "agentMaxTurns", "Observer and summarizer max rounds"],
250
271
  ["Automatic compaction source backlog trigger (tokens):", "compactAfterTokens", "Automatic compaction source backlog trigger (tokens)"],
251
272
  ["New memory pool protection budget (tokens):", "newMemoryPoolMaxTokens", "New memory pool protection budget (tokens)"],
@@ -42,6 +42,7 @@ function formatRunAge(timestamp: number): string {
42
42
 
43
43
  function contemplatorWaitingLabel(waitingFor: Runtime["contemplatorState"]["waitingFor"]): string {
44
44
  switch (waitingFor) {
45
+ case "observer": return "waiting for observer backlog";
45
46
  case "memories": return "waiting for memory threshold";
46
47
  case "responses": return "waiting for response spacing";
47
48
  case "ready": return "ready to launch";
package/src/config.ts CHANGED
@@ -125,11 +125,11 @@ export const OBSERVER_CHUNK_MIN_TOKENS = 256;
125
125
  /**
126
126
  * Fraction of the memory model's context window used for the derived observer
127
127
  * chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
128
- * real tokens by up to ~4x on non-ASCII content, so 0.2 keeps even the worst
129
- * case at ~80% of the window with room left for the system prompt, prior
130
- * memory, and the response.
128
+ * real tokens substantially on non-ASCII content. The estimator remains
129
+ * approximate; the remaining 75% of the advertised window accommodates injected memory, the
130
+ * system prompt, and model output.
131
131
  */
132
- export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
132
+ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.25;
133
133
 
134
134
  /**
135
135
  * Resolve the maximum estimated tokens the observer serializes into one chunk.
@@ -143,7 +143,7 @@ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
143
143
  * after repeated observer failures, or when the extension is enabled mid-way
144
144
  * into a long session) makes every observer call fail, so coverage never
145
145
  * advances and the session can never recover. With the cap, oversized backlogs
146
- * are drained oldest-first across successive runs.
146
+ * are drained oldest-first across successive bounded passes.
147
147
  */
148
148
  export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
149
149
  if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
@@ -9,6 +9,7 @@ import {
9
9
  } from "./compaction-resume.js";
10
10
 
11
11
  const COMPACTION_STATUS_KEY = "observational-memory-compaction";
12
+ export const COMPACTION_CALLBACK_TIMEOUT_MS = 30 * 60_000;
12
13
  type CompactionOrigin = "agent-requested" | "length-stop" | "proactive";
13
14
 
14
15
  type TriggerOptions = {
@@ -37,6 +38,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
37
38
  const { origin, resume, threshold, shortContinuationPrompt } = options;
38
39
  const hasUI = ctx.hasUI;
39
40
  const ui = ctx.ui;
41
+ const generation = runtime.getContextGeneration?.() ?? 0;
40
42
 
41
43
  runtime.compactInFlight = true;
42
44
  runtime.compactOrigin = origin;
@@ -74,23 +76,39 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
74
76
  );
75
77
  }
76
78
  if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
77
- ctx.compact({
78
- onComplete: () => {
79
- runtime.compactInFlight = false;
80
- runtime.compactOrigin = undefined;
81
- if (hasUI && !resume) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
82
- if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
83
- },
84
- onError: (error: { message: string }) => {
85
- runtime.compactInFlight = false;
86
- runtime.compactOrigin = undefined;
87
- if (hasUI) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
88
- if (error.message !== "Compaction cancelled" && hasUI) {
89
- ui?.notify(`pi-contemplator: ${error.message}`, "error");
90
- }
91
- if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
92
- },
93
- });
79
+ let settled = false;
80
+ const callbackTimeout = setTimeout(() => {
81
+ if (settled || generation !== (runtime.getContextGeneration?.() ?? 0)) return;
82
+ settled = true;
83
+ runtime.compactInFlight = false;
84
+ runtime.compactOrigin = undefined;
85
+ if (hasUI) {
86
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
87
+ ui?.notify("pi-contemplator: compaction callback timed out; releasing the compaction lock", "error");
88
+ }
89
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
90
+ }, COMPACTION_CALLBACK_TIMEOUT_MS);
91
+ (callbackTimeout as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
92
+ const settle = (failed: boolean, error?: { message: string }) => {
93
+ if (settled || generation !== (runtime.getContextGeneration?.() ?? 0)) return;
94
+ settled = true;
95
+ clearTimeout(callbackTimeout);
96
+ runtime.compactInFlight = false;
97
+ runtime.compactOrigin = undefined;
98
+ if (hasUI && (failed || !resume)) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
99
+ if (failed && error?.message !== "Compaction cancelled" && hasUI) ui?.notify(`pi-contemplator: ${error?.message ?? "compaction failed"}`, "error");
100
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, failed, shortContinuationPrompt);
101
+ };
102
+ try {
103
+ ctx.compact({
104
+ onComplete: () => settle(false),
105
+ onError: (error: { message: string }) => settle(true, error),
106
+ });
107
+ } catch (error) {
108
+ settled = true;
109
+ clearTimeout(callbackTimeout);
110
+ throw error;
111
+ }
94
112
  } catch (error) {
95
113
  runtime.compactInFlight = false;
96
114
  if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
@@ -4,6 +4,7 @@ import { runObserver } from "../agents/observer/agent.js";
4
4
  import { debugLog, withDebugLogContext } from "../debug-log.js";
5
5
  import { resolveObserverChunkMaxTokens } from "../config.js";
6
6
  import type { ResolveResult, Runtime } from "../runtime.js";
7
+ import { createWorkerStallWatchdog } from "../worker-watchdog.js";
7
8
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
8
9
  import {
9
10
  OM_SUMMARIZER_COMMIT,
@@ -43,28 +44,10 @@ export const SUMMARIZER_STALL_TIMEOUT_MS = 15 * 60_000;
43
44
  export function createSummarizerStallWatchdog(
44
45
  timeoutMs: number,
45
46
  onStall: (signal: AbortSignal) => void,
46
- ): { signal: AbortSignal; progress: () => void; dispose: () => void } {
47
- const controller = new AbortController();
48
- let timer: ReturnType<typeof setTimeout> | undefined;
49
- const progress = () => {
50
- if (controller.signal.aborted) return;
51
- if (timer !== undefined) clearTimeout(timer);
52
- timer = setTimeout(() => {
53
- timer = undefined;
54
- controller.abort(new Error(`summarizer produced no progress for ${Math.round(timeoutMs / 60_000)} minutes`));
55
- onStall(controller.signal);
56
- }, timeoutMs);
57
- (timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
58
- };
59
- progress();
60
- return {
61
- signal: controller.signal,
62
- progress,
63
- dispose: () => {
64
- if (timer !== undefined) clearTimeout(timer);
65
- timer = undefined;
66
- },
67
- };
47
+ ) {
48
+ let watchdog!: ReturnType<typeof createWorkerStallWatchdog>;
49
+ watchdog = createWorkerStallWatchdog("summarizer", timeoutMs, () => onStall(watchdog.signal));
50
+ return watchdog;
68
51
  }
69
52
 
70
53
  function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
@@ -96,6 +79,7 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
96
79
  runtime.resolveFailureNotified = false;
97
80
  return cached;
98
81
  }
82
+ runtime.lastObserverError = cached.reason;
99
83
  debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
100
84
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
101
85
  ctx.ui.notify(`pi-contemplator: ${stage} skipped — ${cached.reason}`, "warning");
@@ -109,6 +93,14 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
109
93
  const launch = (_event: unknown, ctx: ConsolidationCtx) => {
110
94
  maybeLaunchConsolidation(pi, runtime, ctx);
111
95
  };
96
+ pi.on("session_start", (event, ctx) => {
97
+ launch(event, ctx);
98
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
99
+ });
100
+ pi.on("session_tree", (event, ctx) => {
101
+ launch(event, ctx);
102
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
103
+ });
112
104
  pi.on("agent_start", (event, ctx) => {
113
105
  launch(event, ctx);
114
106
  syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
@@ -119,8 +111,10 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
119
111
  });
120
112
  runtime.setAgentActivityListener((ctx) => {
121
113
  runtime.ensureConfig(ctx.cwd);
122
- // Token pools are re-evaluated at every primary-agent progress checkpoint;
123
- // idle wall-clock time has no scheduling meaning.
114
+ // Long autonomous turns may run for hours without agent_start/turn_end.
115
+ // Re-evaluate both source coverage and memory pressure at every primary
116
+ // progress checkpoint so neither worker can silently stop for the turn.
117
+ maybeLaunchConsolidation(pi, runtime, ctx as ConsolidationCtx);
124
118
  scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
125
119
  });
126
120
  runtime.setSettingsUpdateListener((ctx, settings) => {
@@ -166,7 +160,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
166
160
  };
167
161
 
168
162
  const sessionMetadata = debugSessionMetadata(ctx);
169
- void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
163
+ const task = runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
170
164
  enabled: runtime.config.debugLog === true,
171
165
  cwd: ctx.cwd,
172
166
  ...sessionMetadata,
@@ -174,6 +168,13 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
174
168
  }, async () => {
175
169
  await runConsolidationPipeline(pi, runtime, consolidationCtx);
176
170
  }));
171
+ // launchTrackedTask releases its lock before this continuation. If observer
172
+ // setup failed before coverage could move, wake the contemplator once in
173
+ // degraded mode rather than leaving all advisory work gated forever. Future
174
+ // primary activity still retries the observer.
175
+ void task.then(() => {
176
+ if (runtime.lastObserverError) runtime.notifyMemoryUpdate(ctx);
177
+ });
177
178
  }
178
179
 
179
180
  export function launchCompactionObserver(
@@ -227,12 +228,27 @@ export async function runConsolidationPipeline(
227
228
  runtime.consolidationPhase = "observer";
228
229
  runtime.lastObserverStartedAt = Date.now();
229
230
  try {
230
- const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
231
- force: options.forceObserver === true,
232
- entries: options.observerEntries,
233
- contextGeneration,
234
- });
235
- if (observerOutcome === "abort") return;
231
+ // A large backlog is drained in bounded, oldest-first chunks. The normal
232
+ // trigger threshold controls when the batch stops; a static compaction
233
+ // snapshot is intentionally processed only once. Coverage must advance on
234
+ // every iteration, otherwise stop rather than spin on a failed chunk.
235
+ while (true) {
236
+ const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
237
+ const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
238
+ const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
239
+ force: options.forceObserver === true,
240
+ entries: options.observerEntries,
241
+ contextGeneration,
242
+ });
243
+ if (observerOutcome === "abort") return;
244
+ if (options.observerEntries) break;
245
+
246
+ const afterEntries = ctx.sessionManager.getBranch() as Entry[];
247
+ const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
248
+ const remainingTokens = rawTokensSinceObservationCoverage(afterEntries);
249
+ if (afterCoverage <= beforeCoverage || remainingTokens < runtime.config.observeAfterTokens) break;
250
+ debugLog("observer.backlog_continue", { remainingTokens, afterCoverage });
251
+ }
236
252
  } catch (error) {
237
253
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
238
254
  return;
@@ -299,7 +315,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
299
315
  }, async () => {
300
316
  let stalled = false;
301
317
  let successfullyCompleted = false;
318
+ let modelRunAttempted = false;
302
319
  let disposeStallWatchdog = () => {};
320
+ let acceptsSummarizerMessages = true;
303
321
  const startedAt = Date.now();
304
322
  runtime.lastSummarizerStartedAt = startedAt;
305
323
  runtime.lastSummarizerRun = { startedAt, status: "running", messages: [] };
@@ -319,7 +337,8 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
319
337
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: ${reason}; cancelling and leaving the backlog eligible for retry`, "warning");
320
338
  });
321
339
  disposeStallWatchdog = watchdog.dispose;
322
- const result = await runSummarizer({
340
+ modelRunAttempted = true;
341
+ const result = await watchdog.race(runSummarizer({
323
342
  signal: watchdog.signal,
324
343
  model: resolved.model as any,
325
344
  apiKey: resolved.apiKey,
@@ -333,9 +352,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
333
352
  recordUsage: (usage) => runtime.recordAgentUsage(usage),
334
353
  onMessages: (messages) => {
335
354
  watchdog.progress();
336
- if (generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
355
+ if (acceptsSummarizerMessages && generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
337
356
  },
338
- });
357
+ }));
339
358
  if (generation !== runtime.getContextGeneration()) return;
340
359
  if (!result.completed) {
341
360
  runtime.lastSummarizerRun = stalled
@@ -362,6 +381,7 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
362
381
  }
363
382
  throw error;
364
383
  } finally {
384
+ acceptsSummarizerMessages = false;
365
385
  disposeStallWatchdog();
366
386
  if (generation === runtime.getContextGeneration()) {
367
387
  const completedAt = Date.now();
@@ -370,7 +390,11 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
370
390
  const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
371
391
  const target = runtime.config.oldMemoryPoolTargetTokens;
372
392
  runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
373
- successfullyCompleted,
393
+ // A failed/no-progress model pass must not be retried with the
394
+ // identical pool at every primary-agent checkpoint. Require fresh
395
+ // old-memory growth before trying another (potentially different)
396
+ // sample. Model-resolution failures spend no tokens and stay eligible.
397
+ successfullyCompleted || modelRunAttempted,
374
398
  runtime.summarizerNextTriggerTokens,
375
399
  target,
376
400
  postRunPools.oldTokens,
@@ -449,31 +473,45 @@ async function runObserverStage(
449
473
  priorObservations: priorObservations.length,
450
474
  });
451
475
 
452
- const observations = await runObserver({
453
- model: resolved.model as any,
454
- apiKey: resolved.apiKey,
455
- headers: resolved.headers,
456
- priorSummaries,
457
- priorObservations,
458
- chunk,
459
- allowedSourceEntryIds: sourceEntryIds,
460
- maxTurns: runtime.config.agentMaxTurns,
461
- thinkingLevel: runtime.config.model?.thinking ?? "low",
462
- recordUsage: (usage) => runtime.recordAgentUsage(usage),
463
- });
476
+ let observations;
477
+ let failedMessage: string | undefined;
478
+ const observerWatchdog = createWorkerStallWatchdog("observer");
479
+ try {
480
+ observations = await observerWatchdog.race(runObserver({
481
+ model: resolved.model as any,
482
+ apiKey: resolved.apiKey,
483
+ headers: resolved.headers,
484
+ priorSummaries,
485
+ priorObservations,
486
+ chunk,
487
+ allowedSourceEntryIds: sourceEntryIds,
488
+ maxTurns: runtime.config.agentMaxTurns,
489
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
490
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
491
+ onProgress: observerWatchdog.progress,
492
+ signal: observerWatchdog.signal,
493
+ }));
494
+ } catch (error) {
495
+ // A permanently pathological old chunk must not pin every newer source
496
+ // entry and block the contemplator forever. Accepted observations are
497
+ // already returned normally by runObserver; only a zero-progress failure
498
+ // reaches here. Record the failure visibly, then advance this bounded
499
+ // range with an empty coverage marker so catch-up can continue.
500
+ failedMessage = runtime.recordConsolidationStageError(ctx, "observer", error);
501
+ debugLog("observer.failed_chunk_advanced", {
502
+ failedMessage,
503
+ coversUpToId,
504
+ sourceEntryIds,
505
+ chunkTokens,
506
+ });
507
+ observations = undefined;
508
+ } finally {
509
+ observerWatchdog.dispose();
510
+ }
464
511
  if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
465
512
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
466
513
  return "abort";
467
514
  }
468
- if (!observations || observations.length === 0) {
469
- debugLog("observer.empty", { coversUpToId });
470
- if (ctx.hasUI) ctx.ui?.notify(
471
- "pi-contemplator: observer returned no observations",
472
- "warning",
473
- );
474
- return "continue";
475
- }
476
-
477
515
  const currentEntries = ctx.sessionManager.getBranch() as Entry[];
478
516
  let effectiveCoversUpToId = coversUpToId;
479
517
  if (!currentEntries.some((entry) => entry.id === coversUpToId)) {
@@ -488,17 +526,24 @@ async function runObserverStage(
488
526
  compactionId: compaction?.id,
489
527
  });
490
528
  }
491
- const data = buildObservationsRecordedData(observations, effectiveCoversUpToId);
529
+ const accepted = observations ?? [];
530
+ const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
492
531
  if (!data) return "continue";
493
- debugLog("observer.records", {
494
- count: observations.length,
495
- observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
532
+ debugLog(failedMessage ? "observer.failed_coverage" : accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
533
+ count: accepted.length,
534
+ observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
496
535
  coversUpToId: effectiveCoversUpToId,
536
+ ...(failedMessage ? { failedMessage } : {}),
497
537
  });
538
+ // A clean zero-observation verdict and a zero-progress failed chunk both use
539
+ // an empty coverage marker. This prevents low-information or pathological old
540
+ // source from pinning the entire observer/contemplator pipeline forever.
498
541
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
499
- debugLog("observer.appended", { count: observations.length, coversUpToId: effectiveCoversUpToId });
500
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
501
- `pi-contemplator: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
542
+ debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
543
+ if (!failedMessage && shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
544
+ accepted.length > 0
545
+ ? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
546
+ : "pi-contemplator: observer found no new information; processed chunk marked covered",
502
547
  "info",
503
548
  );
504
549
  return "continue";
@@ -2,6 +2,9 @@ import type { Model } from "@earendil-works/pi-ai";
2
2
 
3
3
  export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
4
 
5
+ /** Observer output allowance for difficult, high-volume source chunks. */
6
+ export const OBSERVER_AGENT_LOOP_MAX_TOKENS = 160_000;
7
+
5
8
  /**
6
9
  * Lifetime output-token budget for one structural review request.
7
10
  * Persisted reviewer transcripts carry usage across keep-going iterations,
package/src/runtime.ts CHANGED
@@ -72,7 +72,7 @@ export interface ContemplatorRunState {
72
72
  pendingReviews: number;
73
73
  /** Completed primary-model responses since the previous contemplator run. */
74
74
  responsesSinceRun: number;
75
- waitingFor: "disabled" | "passive" | "memories" | "responses" | "ready" | "running" | "idle";
75
+ waitingFor: "disabled" | "passive" | "observer" | "memories" | "responses" | "ready" | "running" | "idle";
76
76
  lastStartedAt?: number;
77
77
  lastCompletedAt?: number;
78
78
  lastError?: string;
@@ -254,6 +254,16 @@ export class Runtime {
254
254
  // against a different branch and would otherwise leak across sessions
255
255
  // (e.g. a request made in session A compacting session B's branch, or a
256
256
  // never-cleared compactInFlight bricking all future compactions).
257
+ // Detach stale background tasks immediately. Their promise finalizers are
258
+ // identity-guarded below, so an old session can never retain or later clear
259
+ // a lock owned by the new session.
260
+ this.consolidationInFlight = false;
261
+ this.consolidationPromise = null;
262
+ this.consolidationPhase = undefined;
263
+ this.summarizerInFlight = false;
264
+ this.summarizerPromise = null;
265
+ this.reviewInFlight = false;
266
+ this.reviewPromise = null;
257
267
  this.compactInFlight = false;
258
268
  this.compactRequested = false;
259
269
  this.compactContinuationPrompt = undefined;
@@ -336,9 +346,10 @@ export class Runtime {
336
346
  this.consolidationPhase = undefined;
337
347
  this.lastObserverError = undefined;
338
348
  const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
349
+ if (this.consolidationPromise !== promise) return;
339
350
  this.consolidationInFlight = false;
340
351
  this.consolidationPhase = undefined;
341
- if (this.consolidationPromise === promise) this.consolidationPromise = null;
352
+ this.consolidationPromise = null;
342
353
  });
343
354
  this.consolidationPromise = promise;
344
355
  return promise;
@@ -351,9 +362,10 @@ export class Runtime {
351
362
  this.summarizerInFlight = true;
352
363
  this.lastSummarizerError = undefined;
353
364
  const promise = this.launchTrackedTask(ctx, "summarizer", work, (error) => {
365
+ if (this.summarizerPromise !== promise) return;
354
366
  this.summarizerInFlight = false;
355
367
  this.lastSummarizerError = error;
356
- if (this.summarizerPromise === promise) this.summarizerPromise = null;
368
+ this.summarizerPromise = null;
357
369
  });
358
370
  this.summarizerPromise = promise;
359
371
  return promise;
@@ -365,8 +377,9 @@ export class Runtime {
365
377
  if (this.reviewInFlight) return undefined;
366
378
  this.reviewInFlight = true;
367
379
  const promise = this.launchTrackedTask(ctx, "structural review", work, () => {
380
+ if (this.reviewPromise !== promise) return;
368
381
  this.reviewInFlight = false;
369
- if (this.reviewPromise === promise) this.reviewPromise = null;
382
+ this.reviewPromise = null;
370
383
  });
371
384
  this.reviewPromise = promise;
372
385
  return promise;
@@ -74,7 +74,7 @@ function isNonEmptyArray(value: unknown): value is unknown[] {
74
74
  function isValidCoverageEntry(entry: Entry, customType: MemoryCoverageCustomType): entry is Entry & { data: { coversUpToId: string } } {
75
75
  if (entry.type !== "custom" || entry.customType !== customType) return false;
76
76
  if (!isObject(entry.data) || typeof entry.data.coversUpToId !== "string") return false;
77
- if (customType === OM_OBSERVATIONS_RECORDED) return isNonEmptyArray(entry.data.observations);
77
+ if (customType === OM_OBSERVATIONS_RECORDED) return Array.isArray(entry.data.observations);
78
78
  return customType === OM_SUMMARIZER_COMMIT && isNonEmptyArray(entry.data.summaries);
79
79
  }
80
80
 
@@ -235,7 +235,6 @@ export function isObservationsRecordedData(value: unknown): value is Observation
235
235
  if (!isPlainRecord(value)) return false;
236
236
  return (
237
237
  Array.isArray(value.observations) &&
238
- value.observations.length > 0 &&
239
238
  value.observations.every(isObservation) &&
240
239
  isNonEmptyString(value.coversUpToId)
241
240
  );
@@ -345,7 +344,7 @@ export function buildObservationsRecordedData(
345
344
  observations: Observation[],
346
345
  coversUpToId: string,
347
346
  ): ObservationsRecordedEntryData | undefined {
348
- if (observations.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
347
+ if (!isNonEmptyString(coversUpToId)) return undefined;
349
348
  const candidate = { observations, coversUpToId };
350
349
  return isObservationsRecordedData(candidate) ? candidate : undefined;
351
350
  }
@@ -0,0 +1,63 @@
1
+ export const BACKGROUND_WORKER_STALL_TIMEOUT_MS = 15 * 60_000;
2
+ export const BACKGROUND_WORKER_MAX_RUNTIME_MS = 6 * 60 * 60_000;
3
+
4
+ /**
5
+ * No-progress watchdog for background model workers.
6
+ *
7
+ * Aborting the provider is the cooperative path. `race()` is the hard liveness
8
+ * boundary: even a provider that ignores AbortSignal cannot retain a worker's
9
+ * single-flight lock forever. The abandoned promise has handlers installed by
10
+ * Promise.race, so a later rejection is not unhandled.
11
+ */
12
+ export function createWorkerStallWatchdog(
13
+ label: string,
14
+ timeoutMs = BACKGROUND_WORKER_STALL_TIMEOUT_MS,
15
+ onStall?: (error: Error) => void,
16
+ maxRuntimeMs = BACKGROUND_WORKER_MAX_RUNTIME_MS,
17
+ ): {
18
+ signal: AbortSignal;
19
+ progress: () => void;
20
+ race: <T>(work: Promise<T>) => Promise<T>;
21
+ dispose: () => void;
22
+ } {
23
+ const controller = new AbortController();
24
+ let timer: ReturnType<typeof setTimeout> | undefined;
25
+ let maxRuntimeTimer: ReturnType<typeof setTimeout> | undefined;
26
+ let rejectStall!: (error: Error) => void;
27
+ let settled = false;
28
+ const stalled = new Promise<never>((_resolve, reject) => { rejectStall = reject; });
29
+ // Some callers use only the cooperative AbortSignal. Keep the hard-boundary
30
+ // promise observed even before/without a race() call.
31
+ void stalled.catch(() => {});
32
+ const trip = (error: Error) => {
33
+ if (settled || controller.signal.aborted) return;
34
+ controller.abort(error);
35
+ onStall?.(error);
36
+ rejectStall(error);
37
+ };
38
+ maxRuntimeTimer = setTimeout(() => trip(new Error(`${label} exceeded its ${Math.round(maxRuntimeMs / 3_600_000)}-hour runtime ceiling`)), maxRuntimeMs);
39
+ (maxRuntimeTimer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
40
+ const progress = () => {
41
+ if (settled || controller.signal.aborted) return;
42
+ if (timer !== undefined) clearTimeout(timer);
43
+ timer = setTimeout(() => {
44
+ timer = undefined;
45
+ if (settled) return;
46
+ trip(new Error(`${label} produced no progress for ${Math.round(timeoutMs / 60_000)} minutes`));
47
+ }, timeoutMs);
48
+ (timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
49
+ };
50
+ progress();
51
+ return {
52
+ signal: controller.signal,
53
+ progress,
54
+ race: async <T>(work: Promise<T>) => Promise.race([work, stalled]),
55
+ dispose: () => {
56
+ settled = true;
57
+ if (timer !== undefined) clearTimeout(timer);
58
+ if (maxRuntimeTimer !== undefined) clearTimeout(maxRuntimeTimer);
59
+ timer = undefined;
60
+ maxRuntimeTimer = undefined;
61
+ },
62
+ };
63
+ }