@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 +1 -1
- package/src/agents/contemplator/agent.ts +95 -30
- package/src/agents/observer/agent.ts +88 -36
- package/src/agents/observer/prompts.ts +3 -3
- package/src/agents/reviewer/agent.ts +5 -1
- package/src/commands/contemplator-view.ts +7 -5
- package/src/commands/settings.ts +24 -3
- package/src/commands/status.ts +1 -0
- package/src/config.ts +5 -5
- package/src/hooks/compaction-trigger.ts +35 -17
- package/src/hooks/consolidation-trigger.ts +108 -63
- package/src/model-budget.ts +3 -0
- package/src/runtime.ts +17 -4
- package/src/session-ledger/progress.ts +1 -1
- package/src/session-ledger/types.ts +1 -2
- package/src/worker-watchdog.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
668
|
-
this.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
const result = await
|
|
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)
|
|
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
|
-
|
|
850
|
-
this.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
165
|
-
{
|
|
166
|
-
role: "user",
|
|
167
|
-
content: [{ type: "text", text: userText }],
|
|
168
|
-
timestamp: Date.now(),
|
|
169
|
-
},
|
|
170
|
-
];
|
|
188
|
+
END NEW CONVERSATION CHUNK
|
|
171
189
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
202
|
+
const baseConfig: AgentLoopConfig = {
|
|
183
203
|
model,
|
|
184
204
|
apiKey,
|
|
185
205
|
headers,
|
|
186
|
-
maxTokens: boundedMaxTokens(model,
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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" &&
|
|
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,
|
|
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,
|
|
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)
|
|
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 === "
|
|
67
|
-
? "waiting for
|
|
68
|
-
: state.waitingFor === "
|
|
69
|
-
? "waiting for
|
|
70
|
-
: state.waitingFor === "
|
|
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"
|
package/src/commands/settings.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)"],
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
123
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
529
|
+
const accepted = observations ?? [];
|
|
530
|
+
const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
|
|
492
531
|
if (!data) return "continue";
|
|
493
|
-
debugLog("observer.records", {
|
|
494
|
-
count:
|
|
495
|
-
observationTokens:
|
|
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:
|
|
500
|
-
if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
|
|
501
|
-
|
|
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";
|
package/src/model-budget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|