@matthewfl/pi-contemplator 0.1.20 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +10 -10
- package/src/agents/contemplator/agent.ts +42 -34
- package/src/agents/observer/agent.ts +12 -13
- package/src/agents/reviewer/agent.ts +21 -17
- package/src/agents/summarizer/agent.ts +10 -9
- package/src/hooks/consolidation-trigger.ts +5 -7
- package/src/runtime.ts +13 -9
package/README.md
CHANGED
|
@@ -157,6 +157,8 @@ The observer and summarizer provide the durable memory substrate. The contemplat
|
|
|
157
157
|
|
|
158
158
|
## Installation
|
|
159
159
|
|
|
160
|
+
`pi-contemplator` 0.3.0 requires **Pi 0.87.x**.
|
|
161
|
+
|
|
160
162
|
Install the published package through Pi:
|
|
161
163
|
|
|
162
164
|
```bash
|
|
@@ -201,4 +203,4 @@ npm run typecheck
|
|
|
201
203
|
|
|
202
204
|
## License
|
|
203
205
|
|
|
204
|
-
MIT. See [LICENSE](LICENSE).
|
|
206
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -44,17 +44,17 @@
|
|
|
44
44
|
"test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-history.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-agent-core": "
|
|
48
|
-
"@earendil-works/pi-ai": "
|
|
49
|
-
"@earendil-works/pi-coding-agent": "
|
|
50
|
-
"@earendil-works/pi-tui": "
|
|
47
|
+
"@earendil-works/pi-agent-core": "^0.87.0",
|
|
48
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
50
|
+
"@earendil-works/pi-tui": "^0.87.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
54
|
-
"@earendil-works/pi-ai": "^0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
56
|
-
"@earendil-works/pi-server": "^0.
|
|
57
|
-
"@earendil-works/pi-tui": "^0.
|
|
53
|
+
"@earendil-works/pi-agent-core": "^0.87.0",
|
|
54
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
56
|
+
"@earendil-works/pi-server": "^0.87.0",
|
|
57
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
58
58
|
"@types/node": "^22.0.0",
|
|
59
59
|
"typebox": "^1.1.38",
|
|
60
60
|
"typescript": "^5.6.0",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import { Type, type Message, type Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Static } from "typebox";
|
|
4
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
@@ -9,7 +8,7 @@ import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullP
|
|
|
9
8
|
import { hashId } from "../../ids.js";
|
|
10
9
|
import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
|
|
11
10
|
import { createRecallAgentTool } from "../../tools/recall-observation.js";
|
|
12
|
-
import type
|
|
11
|
+
import { modelRegistryStream, type MemoryUpdateCtx, type Runtime } from "../../runtime.js";
|
|
13
12
|
import { logAgentStreamError } from "../stream-errors.js";
|
|
14
13
|
import { debugLog, withDebugLogContext } from "../../debug-log.js";
|
|
15
14
|
import { boundedMaxTokens, AGENT_LOOP_MAX_TOKENS } from "../../model-budget.js";
|
|
@@ -92,6 +91,10 @@ type Intervention =
|
|
|
92
91
|
| { kind: "review"; request: Omit<StructuralReviewRequest, "createdAt" | "requestedBy"> }
|
|
93
92
|
| { kind: "none" };
|
|
94
93
|
|
|
94
|
+
function isResumableReviewerMessage(message: unknown): message is AgentMessage {
|
|
95
|
+
return !!message && typeof message === "object" && (message as { role?: unknown }).role !== "system";
|
|
96
|
+
}
|
|
97
|
+
|
|
95
98
|
type ReviewerSession = {
|
|
96
99
|
scope: StructuralReviewRequest["scope"];
|
|
97
100
|
history: AgentMessage[];
|
|
@@ -108,8 +111,7 @@ type QueueStructuralReviewOptions = {
|
|
|
108
111
|
requestArgs: Extract<Intervention, { kind: "review" }>["request"];
|
|
109
112
|
branchEntries: Entry[];
|
|
110
113
|
model: Model<any>;
|
|
111
|
-
|
|
112
|
-
headers: Record<string, string> | undefined;
|
|
114
|
+
streamFn: StreamFn;
|
|
113
115
|
sessionGeneration: number;
|
|
114
116
|
};
|
|
115
117
|
|
|
@@ -117,8 +119,7 @@ type LaunchStructuralReviewOptions = {
|
|
|
117
119
|
ctx: MemoryUpdateCtx;
|
|
118
120
|
request: StructuralReviewRequest;
|
|
119
121
|
model: Model<any>;
|
|
120
|
-
|
|
121
|
-
headers: Record<string, string> | undefined;
|
|
122
|
+
streamFn: StreamFn;
|
|
122
123
|
sessionGeneration: number;
|
|
123
124
|
key?: string;
|
|
124
125
|
history?: AgentMessage[];
|
|
@@ -567,7 +568,7 @@ export class Contemplator {
|
|
|
567
568
|
// Backward compatibility for the old, expensive full-transcript snapshots.
|
|
568
569
|
this.reviewerSessions.set(state.reviewRequestId, {
|
|
569
570
|
scope: state.scope,
|
|
570
|
-
history: state.history.filter(
|
|
571
|
+
history: state.history.filter(isResumableReviewerMessage),
|
|
571
572
|
checkpointEntryId: entry.id,
|
|
572
573
|
messageEntryIds: [],
|
|
573
574
|
foldedEntryIds: new Set(),
|
|
@@ -594,8 +595,10 @@ export class Contemplator {
|
|
|
594
595
|
// (turn_end), so fold each message entry at most once per live session.
|
|
595
596
|
if (!session.foldedEntryIds.has(entry.id)) {
|
|
596
597
|
session.foldedEntryIds.add(entry.id);
|
|
597
|
-
|
|
598
|
-
|
|
598
|
+
if (isResumableReviewerMessage(data.message)) {
|
|
599
|
+
session.history.push(data.message);
|
|
600
|
+
session.messageEntryIds.push(entry.id);
|
|
601
|
+
}
|
|
599
602
|
}
|
|
600
603
|
}
|
|
601
604
|
}
|
|
@@ -807,6 +810,7 @@ export class Contemplator {
|
|
|
807
810
|
debugLog("contemplator.flush_stale", { reason: "session_changed" });
|
|
808
811
|
return;
|
|
809
812
|
}
|
|
813
|
+
const streamFn = modelRegistryStream(ctx.modelRegistry);
|
|
810
814
|
const selectedModel = resolved.model as { provider?: unknown; id?: unknown; contextWindow?: unknown };
|
|
811
815
|
debugLog("contemplator.model_resolved", {
|
|
812
816
|
provider: selectedModel.provider,
|
|
@@ -875,33 +879,34 @@ export class Contemplator {
|
|
|
875
879
|
const supportsReasoning = (resolved.model as { reasoning?: unknown }).reasoning === true;
|
|
876
880
|
const config: AgentLoopConfig & { onPayload?: (payload: unknown) => unknown } = {
|
|
877
881
|
model: resolved.model as Model<any>,
|
|
878
|
-
apiKey: resolved.apiKey,
|
|
879
|
-
headers: resolved.headers,
|
|
880
882
|
maxTokens: boundedMaxTokens(resolved.model as Model<any>, AGENT_LOOP_MAX_TOKENS),
|
|
881
883
|
convertToLlm: (messages) => messages as Message[],
|
|
882
884
|
toolExecution: "sequential",
|
|
883
885
|
// A clean final-action call is the end of the contemplator turn. Do not
|
|
884
886
|
// spend another model request asking it to narrate after its decision.
|
|
885
887
|
// Citation warnings leave the loop open so it can correct the action.
|
|
886
|
-
|
|
888
|
+
finishTurn: ({ message }) => {
|
|
889
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
|
|
890
|
+
return intervention !== undefined && !finalActionWarned ? { action: "end" } : undefined;
|
|
891
|
+
},
|
|
887
892
|
...(supportsReasoning && selectedThinkingLevel !== "off" ? { reasoning: selectedThinkingLevel } : {}),
|
|
888
893
|
};
|
|
889
894
|
const runMessages: AgentMessage[] = [];
|
|
890
895
|
let nextPrompt = prompt;
|
|
891
896
|
for (let invocation = 1; invocation <= CONTEMPLATOR_MAX_INVOCATIONS && !intervention; invocation++) {
|
|
892
897
|
runMessages.push(nextPrompt);
|
|
893
|
-
const context: AgentContext = {
|
|
898
|
+
const context: AgentContext = { messages: [{ role: "system", content: buildContemplatorSystemPrompt(reviewerEnabled), toolsAdded: tools, timestamp: Date.now() }, ...this.history, ...runMessages.slice(0, -1)], tools };
|
|
894
899
|
const api = (resolved.model as Model<any>).api;
|
|
895
900
|
const invocationConfig: AgentLoopConfig & { onPayload?: (payload: unknown) => unknown } = invocation === 1 ? config : {
|
|
896
901
|
...config,
|
|
897
902
|
onPayload: (payload) => forceRequiredToolPayload(payload, api),
|
|
898
903
|
};
|
|
899
|
-
// SimpleStreamOptions
|
|
900
|
-
//
|
|
904
|
+
// Provider-neutral SimpleStreamOptions types choice as auto/none, while
|
|
905
|
+
// individual provider APIs also support required/any. Preserve the
|
|
901
906
|
// runtime hint and final-payload enforcement without weakening base types.
|
|
902
907
|
if (invocation > 1) (invocationConfig as any).toolChoice = requiredToolChoice(api);
|
|
903
908
|
workerWatchdog.progress();
|
|
904
|
-
const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal,
|
|
909
|
+
const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal, streamFn);
|
|
905
910
|
const result = await workerWatchdog.race((async () => {
|
|
906
911
|
for await (const event of stream) {
|
|
907
912
|
workerWatchdog!.progress();
|
|
@@ -966,8 +971,7 @@ export class Contemplator {
|
|
|
966
971
|
requestArgs: intervention.request,
|
|
967
972
|
branchEntries,
|
|
968
973
|
model: reviewerModel.model as Model<any>,
|
|
969
|
-
|
|
970
|
-
headers: reviewerModel.headers,
|
|
974
|
+
streamFn,
|
|
971
975
|
sessionGeneration,
|
|
972
976
|
});
|
|
973
977
|
}
|
|
@@ -975,7 +979,7 @@ export class Contemplator {
|
|
|
975
979
|
if (sessionGeneration === this.sessionGeneration) {
|
|
976
980
|
workerWatchdog.progress();
|
|
977
981
|
try {
|
|
978
|
-
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>,
|
|
982
|
+
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, streamFn, sessionGeneration, flushEpoch, workerWatchdog.signal));
|
|
979
983
|
} catch (compactionError) {
|
|
980
984
|
// The intervention and its durable messages are already complete. Private
|
|
981
985
|
// history maintenance must never relabel that successful work as failed.
|
|
@@ -1056,10 +1060,10 @@ export class Contemplator {
|
|
|
1056
1060
|
// designed for agents that run for hours; a probe must be injected after
|
|
1057
1061
|
// the current tool-call batch, before the very next model request. Never
|
|
1058
1062
|
// change this to nextTurn: that can postpone delivery until a user prompt.
|
|
1059
|
-
// IMPORTANT: omit triggerTurn entirely. Pi
|
|
1060
|
-
// triggerTurn
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
+
// IMPORTANT: omit triggerTurn entirely. Pi queues a streaming steer only
|
|
1064
|
+
// when triggerTurn is not explicitly false; false uses the deferred custom-
|
|
1065
|
+
// message path instead of steering the active run. When idle, omission merely
|
|
1066
|
+
// appends the message and does not manufacture a new agent turn.
|
|
1063
1067
|
// Whether Pi is currently running or idle, sendMessage owns this probe in an
|
|
1064
1068
|
// in-memory steer queue until message_end drains it. Track both cases so an
|
|
1065
1069
|
// unrelated observer update or compaction callback cannot restore and enqueue
|
|
@@ -1084,7 +1088,7 @@ export class Contemplator {
|
|
|
1084
1088
|
}
|
|
1085
1089
|
|
|
1086
1090
|
private queueStructuralReview(options: QueueStructuralReviewOptions): void {
|
|
1087
|
-
const { ctx, requestArgs, branchEntries, model,
|
|
1091
|
+
const { ctx, requestArgs, branchEntries, model, streamFn, sessionGeneration } = options;
|
|
1088
1092
|
const requestForKey: RequestReviewArgs = { scope: requestArgs.scope, evidence: requestArgs.evidence, concern: requestArgs.concern, review_focus: requestArgs.reviewFocus, constraints: requestArgs.constraints };
|
|
1089
1093
|
const key = reviewRequestKey(requestForKey);
|
|
1090
1094
|
const duplicateRequest = branchEntries.some((entry) => isReviewRequestEntry(entry) && reviewRequestKey({ scope: entry.data.request.scope, evidence: entry.data.request.evidence, concern: entry.data.request.concern, review_focus: entry.data.request.reviewFocus, constraints: entry.data.request.constraints }) === key);
|
|
@@ -1095,11 +1099,11 @@ export class Contemplator {
|
|
|
1095
1099
|
const request: StructuralReviewRequest = { ...requestArgs, id: requestArgs.id, createdAt: Date.now(), requestedBy: "contemplator" };
|
|
1096
1100
|
this.pi.appendEntry(OM_REVIEW_REQUEST, { version: 1, request });
|
|
1097
1101
|
this.markTipPersisted(ctx);
|
|
1098
|
-
this.launchStructuralReview({ ctx, request, model,
|
|
1102
|
+
this.launchStructuralReview({ ctx, request, model, streamFn, sessionGeneration, key });
|
|
1099
1103
|
}
|
|
1100
1104
|
|
|
1101
1105
|
private launchStructuralReview(options: LaunchStructuralReviewOptions): boolean {
|
|
1102
|
-
const { ctx, request, model,
|
|
1106
|
+
const { ctx, request, model, streamFn, sessionGeneration, key } = options;
|
|
1103
1107
|
if (this.runtime.reviewInFlight || this.inFlightReviewIds.has(request.id)) return false;
|
|
1104
1108
|
// Do not spin a no-progress reviewer repeatedly in one live session. The
|
|
1105
1109
|
// request stays pending and a later session/tree restoration resumes it.
|
|
@@ -1114,7 +1118,7 @@ export class Contemplator {
|
|
|
1114
1118
|
try {
|
|
1115
1119
|
debugLog("reviewer.started", { reviewRequestId: request.id, scope: request.scope, resumed: session.history.length > 0 });
|
|
1116
1120
|
const result = await watchdog.race(runStructuralReview({
|
|
1117
|
-
request, model,
|
|
1121
|
+
request, model, streamFn,
|
|
1118
1122
|
signal: watchdog.signal,
|
|
1119
1123
|
onProgress: watchdog.progress,
|
|
1120
1124
|
getBranch: () => ctx.sessionManager.getBranch() as Entry[],
|
|
@@ -1124,6 +1128,7 @@ export class Contemplator {
|
|
|
1124
1128
|
watchdog.progress();
|
|
1125
1129
|
if (!acceptsMessages || sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
|
|
1126
1130
|
for (const message of messages) {
|
|
1131
|
+
if (!isResumableReviewerMessage(message)) continue;
|
|
1127
1132
|
session.history.push(message);
|
|
1128
1133
|
const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
|
|
1129
1134
|
if (entryId) {
|
|
@@ -1151,8 +1156,8 @@ export class Contemplator {
|
|
|
1151
1156
|
debugLog(result.outcome === "proposal" ? "reviewer.proposal_created" : "reviewer.no_proposal", { reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope });
|
|
1152
1157
|
if (result.outcome === "proposal") {
|
|
1153
1158
|
const notice = `BACKGROUND ${result.scope.toUpperCase()} REVIEW PROPOSAL [${result.id}]\n\nIMPORTANT: This is only an abbreviated notice, not the full proposal. When this proposal is relevant to the current work, call the recall tool with memory id [${result.id}] before deciding whether or how to act on it.\n\nSUMMARY:\n${result.summary}\n\nThis is advisory. After recalling memory [${result.id}], evaluate the full proposal against the actual environment and current work.`;
|
|
1154
|
-
// As with probes, triggerTurn
|
|
1155
|
-
//
|
|
1159
|
+
// As with probes, omit triggerTurn so a streaming run receives this
|
|
1160
|
+
// through its steer queue without manufacturing a turn while idle.
|
|
1156
1161
|
this.pi.sendMessage({ customType: REVIEW_PROPOSAL_MESSAGE, content: notice, display: this.runtime.config.showContemplatorMessages, details: { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope } }, { deliverAs: "steer" });
|
|
1157
1162
|
this.pi.appendEntry(OM_REVIEWER_NOTICE, { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope, content: notice });
|
|
1158
1163
|
this.markTipPersisted(ctx);
|
|
@@ -1216,7 +1221,7 @@ export class Contemplator {
|
|
|
1216
1221
|
debugLog("reviewer.resume_skipped", { reviewRequestId: request.id, reason: resolved.ok ? "session_changed" : resolved.reason });
|
|
1217
1222
|
return;
|
|
1218
1223
|
}
|
|
1219
|
-
this.launchStructuralReview({ ctx, request, model: resolved.model as Model<any>,
|
|
1224
|
+
this.launchStructuralReview({ ctx, request, model: resolved.model as Model<any>, streamFn: modelRegistryStream(ctx.modelRegistry), sessionGeneration: generation, history: this.reviewerSessions.get(request.id)?.history ?? [] });
|
|
1220
1225
|
} finally {
|
|
1221
1226
|
this.resolvingReviewIds.delete(request.id);
|
|
1222
1227
|
}
|
|
@@ -1263,7 +1268,7 @@ export class Contemplator {
|
|
|
1263
1268
|
return own?.id;
|
|
1264
1269
|
}
|
|
1265
1270
|
|
|
1266
|
-
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>,
|
|
1271
|
+
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, streamFn: StreamFn, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
|
|
1267
1272
|
const history = this.history.slice();
|
|
1268
1273
|
const historyEntryIds = this.historyEntryIds.slice();
|
|
1269
1274
|
const historyTokens = contemplatorHistoryTokens(history);
|
|
@@ -1287,10 +1292,13 @@ export class Contemplator {
|
|
|
1287
1292
|
history.slice(0, prefixEnd),
|
|
1288
1293
|
model,
|
|
1289
1294
|
CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
|
|
1290
|
-
|
|
1291
|
-
|
|
1295
|
+
undefined,
|
|
1296
|
+
undefined,
|
|
1292
1297
|
signal,
|
|
1293
1298
|
CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
|
|
1299
|
+
undefined,
|
|
1300
|
+
undefined,
|
|
1301
|
+
streamFn,
|
|
1294
1302
|
);
|
|
1295
1303
|
|
|
1296
1304
|
let prefixEnd = initialPrefixEnd;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } 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
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import type { Static } from "typebox";
|
|
6
5
|
import { hashId } from "../../ids.js";
|
|
7
6
|
import { replayTruncatedThinkingAsText } from "../replay-truncated-thinking.js";
|
|
@@ -15,8 +14,7 @@ import type { LlmUsageInput } from "../../runtime.js";
|
|
|
15
14
|
|
|
16
15
|
interface RunObserverArgs {
|
|
17
16
|
model: Model<any>;
|
|
18
|
-
|
|
19
|
-
headers?: Record<string, string>;
|
|
17
|
+
streamFn: StreamFn;
|
|
20
18
|
priorSummaries?: string[];
|
|
21
19
|
priorObservations: string[];
|
|
22
20
|
chunk: string;
|
|
@@ -108,7 +106,7 @@ export function normalizeSourceEntryIds(
|
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
111
|
-
const { model,
|
|
109
|
+
const { model, priorSummaries = [], priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
112
110
|
const conversation = chunk.trim();
|
|
113
111
|
if (!conversation) return undefined;
|
|
114
112
|
|
|
@@ -205,14 +203,15 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
|
|
|
205
203
|
let turnCount = 0;
|
|
206
204
|
const baseConfig: AgentLoopConfig = {
|
|
207
205
|
model,
|
|
208
|
-
apiKey,
|
|
209
|
-
headers,
|
|
210
206
|
maxTokens: boundedMaxTokens(model, OBSERVER_AGENT_LOOP_MAX_TOKENS),
|
|
211
207
|
convertToLlm: replayTruncatedThinkingAsText,
|
|
212
208
|
toolExecution: "sequential",
|
|
213
|
-
|
|
209
|
+
finishTurn: ({ message }) => {
|
|
210
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
|
|
214
211
|
turnCount++;
|
|
215
|
-
return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns)
|
|
212
|
+
return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns)
|
|
213
|
+
? { action: "end" }
|
|
214
|
+
: undefined;
|
|
216
215
|
},
|
|
217
216
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
218
217
|
};
|
|
@@ -222,10 +221,10 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
|
|
|
222
221
|
let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
|
|
223
222
|
let lengthAttempts = 0;
|
|
224
223
|
const runInvocation = async (prompt: Message, afterLength = false): Promise<void> => {
|
|
224
|
+
const tools = [recordObservations as AgentTool<any>, doneTool];
|
|
225
225
|
const context: AgentContext = {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
tools: [recordObservations as AgentTool<any>, doneTool],
|
|
226
|
+
messages: [{ role: "system", content: OBSERVER_SYSTEM, toolsAdded: tools, timestamp: Date.now() }, ...history],
|
|
227
|
+
tools,
|
|
229
228
|
};
|
|
230
229
|
// Publish a live launch-local transcript for /om:view observer. Keep an
|
|
231
230
|
// invocation-local list because agentLoop owns its internal context copy.
|
|
@@ -234,7 +233,7 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
|
|
|
234
233
|
const invocationConfig: AgentLoopConfig = afterLength && reasoning
|
|
235
234
|
? { ...baseConfig, reasoning: "minimal" }
|
|
236
235
|
: baseConfig;
|
|
237
|
-
const stream = loop([prompt], context, invocationConfig, signal,
|
|
236
|
+
const stream = loop([prompt], context, invocationConfig, signal, args.streamFn);
|
|
238
237
|
for await (const event of stream) {
|
|
239
238
|
args.onProgress?.();
|
|
240
239
|
logAgentStreamError("observer", event);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
3
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
4
3
|
import { hashId } from "../../ids.js";
|
|
5
4
|
import { boundedMaxTokens, REVIEWER_TOTAL_TOKEN_LIMIT } from "../../model-budget.js";
|
|
6
5
|
import type { LlmUsageInput } from "../../runtime.js";
|
|
@@ -22,8 +21,7 @@ export const REVIEWER_MAX_INVOCATIONS_PER_LAUNCH = 5;
|
|
|
22
21
|
export interface RunStructuralReviewArgs {
|
|
23
22
|
request: StructuralReviewRequest;
|
|
24
23
|
model: Model<any>;
|
|
25
|
-
|
|
26
|
-
headers?: Record<string, string>;
|
|
24
|
+
streamFn: StreamFn;
|
|
27
25
|
getBranch: () => Entry[];
|
|
28
26
|
signal?: AbortSignal;
|
|
29
27
|
agentLoop?: typeof agentLoop;
|
|
@@ -144,8 +142,8 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
144
142
|
// agentLoop can make several model calls while following tool calls. Wrap its
|
|
145
143
|
// stream function so every internal response gets only the lifetime budget
|
|
146
144
|
// remaining after earlier responses, not a fresh per-turn allowance.
|
|
147
|
-
const budgetedStreamSimple:
|
|
148
|
-
const response =
|
|
145
|
+
const budgetedStreamSimple: StreamFn = async (model: Model<any>, context: any, options: any) => {
|
|
146
|
+
const response = await args.streamFn(model, context, {
|
|
149
147
|
...options,
|
|
150
148
|
maxTokens: boundedMaxTokens(model, remainingBudget()),
|
|
151
149
|
});
|
|
@@ -160,21 +158,22 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
160
158
|
}
|
|
161
159
|
return message;
|
|
162
160
|
};
|
|
163
|
-
return { [Symbol.asyncIterator]: () => response[Symbol.asyncIterator](), result } as
|
|
164
|
-
}
|
|
161
|
+
return { [Symbol.asyncIterator]: () => response[Symbol.asyncIterator](), result } as typeof response;
|
|
162
|
+
};
|
|
165
163
|
const config: AgentLoopConfig = {
|
|
166
164
|
model: args.model,
|
|
167
|
-
apiKey: args.apiKey,
|
|
168
|
-
headers: args.headers,
|
|
169
165
|
maxTokens: boundedMaxTokens(args.model, remainingBudget()),
|
|
170
166
|
convertToLlm: (messages) => messages as Message[],
|
|
171
167
|
toolExecution: "sequential",
|
|
172
|
-
|
|
168
|
+
finishTurn: ({ message }) => {
|
|
169
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
|
|
170
|
+
return terminal !== undefined || remainingBudget() === 0 ? { action: "end" } : undefined;
|
|
171
|
+
},
|
|
173
172
|
};
|
|
174
173
|
const promptMessage = prompt as AgentMessage;
|
|
175
174
|
// agentLoop receives the new prompt separately. Its context must therefore
|
|
176
175
|
// contain only prior messages, otherwise a resumed prompt is sent twice.
|
|
177
|
-
const context: AgentContext = {
|
|
176
|
+
const context: AgentContext = { messages: [{ role: "system", content: buildReviewerSystemPrompt(args.request.scope), toolsAdded: tools, timestamp: Date.now() }, ...history], tools };
|
|
178
177
|
history.push(promptMessage);
|
|
179
178
|
args.onMessages?.([promptMessage]);
|
|
180
179
|
const stream = loop([prompt], context, config, args.signal, budgetedStreamSimple);
|
|
@@ -183,9 +182,14 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
183
182
|
logAgentStreamError("reviewer", event);
|
|
184
183
|
}
|
|
185
184
|
const newMessages = await stream.result();
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
// agentLoop returns the supplied prompt in its per-invocation result. It is
|
|
186
|
+
// already persisted above, so exclude that exact input alongside any
|
|
187
|
+
// synthesized tool-declaration system messages. declareToolChanges preserves
|
|
188
|
+
// non-system prompt object identity even when it inserts a system delta.
|
|
189
|
+
const resumableMessages = newMessages.filter((message) => message !== promptMessage && message.role !== "system");
|
|
190
|
+
history.push(...resumableMessages);
|
|
191
|
+
args.onMessages?.(resumableMessages);
|
|
192
|
+
const assistants = resumableMessages.filter((message) => message.role === "assistant");
|
|
189
193
|
if (args.recordUsage) {
|
|
190
194
|
for (const message of assistants) {
|
|
191
195
|
const usage = (message as { usage?: LlmUsageInput }).usage;
|
|
@@ -196,8 +200,8 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
196
200
|
// assistant usage that the real stream wrapper did not already account for.
|
|
197
201
|
const reportedOutputTokens = assistantOutputTokens(assistants);
|
|
198
202
|
totalOutputTokens += Math.max(0, reportedOutputTokens - streamedOutputTokens);
|
|
199
|
-
// A warned terminal call deliberately leaves
|
|
200
|
-
//
|
|
203
|
+
// A warned terminal call deliberately leaves finishTurn undecided so the
|
|
204
|
+
// reviewer can search/recall and replace it. If the reviewer instead
|
|
201
205
|
// ends its turn, honor the staged outcome exactly as the warning promised.
|
|
202
206
|
if (!terminal && warnedTerminal) {
|
|
203
207
|
terminal = warnedTerminal;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } 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
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
4
|
import type { Static } from "typebox";
|
|
6
5
|
import { debugLog } from "../../debug-log.js";
|
|
7
6
|
import { hashId } from "../../ids.js";
|
|
@@ -48,8 +47,7 @@ const MEMORY_ID_GLOBAL = new RegExp(MEMORY_ID_SOURCE, "g");
|
|
|
48
47
|
|
|
49
48
|
export type RunSummarizerArgs = {
|
|
50
49
|
model: Model<any>;
|
|
51
|
-
|
|
52
|
-
headers?: Record<string, string>;
|
|
50
|
+
streamFn: StreamFn;
|
|
53
51
|
getBranch: () => Entry[];
|
|
54
52
|
/** Target size for the old, summarizer-eligible memory pool. */
|
|
55
53
|
targetTokens: number;
|
|
@@ -531,15 +529,13 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
|
|
|
531
529
|
|
|
532
530
|
const runOnce = async (text: string, requireToolCall: boolean): Promise<string | undefined> => {
|
|
533
531
|
const prompt: Message = { role: "user", content: [{ type: "text", text }], timestamp: Date.now() };
|
|
534
|
-
const context: AgentContext = {
|
|
532
|
+
const context: AgentContext = { messages: [{ role: "system", content: SUMMARIZER_SYSTEM, toolsAdded: tools, timestamp: Date.now() }, ...history], tools };
|
|
535
533
|
const estimatedInputTokens = estimateStringTokens(SUMMARIZER_SYSTEM) + toolDefinitionTokens + estimateStringTokens(JSON.stringify([...history, prompt]));
|
|
536
534
|
const contextAvailableOutput = Math.max(1, contextWindow - estimatedInputTokens - SUMMARIZER_CONTEXT_RESERVE_TOKENS);
|
|
537
535
|
const maxOutputTokens = Math.min(SUMMARIZER_MAX_OUTPUT_TOKENS, contextAvailableOutput);
|
|
538
536
|
let turnCount = 0;
|
|
539
537
|
const config: AgentLoopConfig = {
|
|
540
538
|
model: args.model,
|
|
541
|
-
apiKey: args.apiKey,
|
|
542
|
-
headers: args.headers,
|
|
543
539
|
maxTokens: boundedMaxTokens(args.model, maxOutputTokens),
|
|
544
540
|
convertToLlm: replayTruncatedThinkingAsText,
|
|
545
541
|
toolExecution: "sequential",
|
|
@@ -550,7 +546,12 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
|
|
|
550
546
|
if (calls.length > 1) return { block: true, reason: "Call done alone in a later response after sibling tool results are visible." };
|
|
551
547
|
return undefined;
|
|
552
548
|
},
|
|
553
|
-
|
|
549
|
+
finishTurn: ({ message }) => {
|
|
550
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
|
|
551
|
+
return completedWithDone || (effectiveMaxTurns !== undefined && ++turnCount >= effectiveMaxTurns)
|
|
552
|
+
? { action: "end" }
|
|
553
|
+
: undefined;
|
|
554
|
+
},
|
|
554
555
|
...(requireToolCall ? { onPayload: (payload: unknown) => forceRequiredToolPayload(payload, args.model.api) } : {}),
|
|
555
556
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
556
557
|
};
|
|
@@ -560,7 +561,7 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
|
|
|
560
561
|
if (requireToolCall) (config as any).toolChoice = requiredToolChoice(args.model.api);
|
|
561
562
|
history.push(prompt as AgentMessage);
|
|
562
563
|
args.onMessages?.(history.slice());
|
|
563
|
-
const stream = loop([prompt], context, config, args.signal,
|
|
564
|
+
const stream = loop([prompt], context, config, args.signal, args.streamFn);
|
|
564
565
|
// agentLoop emits message_start/message_end for the supplied prompt, so
|
|
565
566
|
// seed live checkpoints without our already-pushed copy of that prompt.
|
|
566
567
|
const liveMessages = history.slice(0, -1);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { runSummarizer } from "../agents/summarizer/agent.js";
|
|
3
3
|
import { runObserver } from "../agents/observer/agent.js";
|
|
4
4
|
import { debugLog, withDebugLogContext } from "../debug-log.js";
|
|
5
5
|
import { resolveObserverChunkMaxTokens } from "../config.js";
|
|
6
|
-
import type
|
|
6
|
+
import { modelRegistryStream, type ResolveResult, type Runtime } from "../runtime.js";
|
|
7
7
|
import { createWorkerStallWatchdog } from "../worker-watchdog.js";
|
|
8
8
|
import { boundedMaxTokens, OBSERVER_AGENT_LOOP_MAX_TOKENS } from "../model-budget.js";
|
|
9
9
|
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
@@ -30,7 +30,7 @@ export type ConsolidationCtx = {
|
|
|
30
30
|
hasUI: boolean;
|
|
31
31
|
ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
|
|
32
32
|
model: unknown;
|
|
33
|
-
modelRegistry:
|
|
33
|
+
modelRegistry: ExtensionContext["modelRegistry"];
|
|
34
34
|
sessionManager: {
|
|
35
35
|
getBranch: () => readonly unknown[];
|
|
36
36
|
getSessionId?: () => string;
|
|
@@ -400,8 +400,7 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
|
|
|
400
400
|
const result = await watchdog.race(runSummarizer({
|
|
401
401
|
signal: watchdog.signal,
|
|
402
402
|
model: resolved.model as any,
|
|
403
|
-
|
|
404
|
-
headers: resolved.headers,
|
|
403
|
+
streamFn: modelRegistryStream(ctx.modelRegistry),
|
|
405
404
|
getBranch: () => ctx.sessionManager.getBranch() as Entry[],
|
|
406
405
|
targetTokens: runtime.config.oldMemoryPoolTargetTokens,
|
|
407
406
|
newPoolMaxTokens: runtime.config.newMemoryPoolMaxTokens,
|
|
@@ -569,8 +568,7 @@ async function runObserverStage(
|
|
|
569
568
|
try {
|
|
570
569
|
observations = await observerWatchdog.race(runObserver({
|
|
571
570
|
model: resolved.model as any,
|
|
572
|
-
|
|
573
|
-
headers: resolved.headers,
|
|
571
|
+
streamFn: modelRegistryStream(ctx.modelRegistry),
|
|
574
572
|
priorSummaries,
|
|
575
573
|
priorObservations,
|
|
576
574
|
chunk,
|
package/src/runtime.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
3
|
import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
|
|
2
4
|
|
|
3
5
|
export type ResolveResult =
|
|
4
|
-
| { ok: true; model: unknown
|
|
6
|
+
| { ok: true; model: unknown }
|
|
5
7
|
| { ok: false; reason: string };
|
|
6
8
|
|
|
9
|
+
/** Route background model calls through Pi's configured providers and request-time authentication. */
|
|
10
|
+
export function modelRegistryStream(modelRegistry: ExtensionContext["modelRegistry"]): StreamFn {
|
|
11
|
+
return (model, context, options) => modelRegistry.streamSimple(model, context, options);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
type NotifyLevel = "warning" | "info" | "error";
|
|
8
15
|
type Notify = (message: string, type?: NotifyLevel) => void;
|
|
9
16
|
export type ConsolidationPhase = "observer";
|
|
@@ -39,7 +46,7 @@ export type SessionSettings = Partial<Pick<Config,
|
|
|
39
46
|
|
|
40
47
|
export interface ResolveCtx {
|
|
41
48
|
model: unknown;
|
|
42
|
-
modelRegistry:
|
|
49
|
+
modelRegistry: ExtensionContext["modelRegistry"];
|
|
43
50
|
hasUI: boolean;
|
|
44
51
|
ui?: { notify: Notify };
|
|
45
52
|
}
|
|
@@ -342,13 +349,10 @@ export class Runtime {
|
|
|
342
349
|
);
|
|
343
350
|
}
|
|
344
351
|
}
|
|
345
|
-
if (!model) return { ok: false, reason: "no model available (session has no model and no
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return { ok: false, reason: `no API key for provider "${provider}"` };
|
|
350
|
-
}
|
|
351
|
-
return { ok: true, model, apiKey: auth.apiKey as string, headers: auth.headers as Record<string, string> | undefined };
|
|
352
|
+
if (!model) return { ok: false, reason: "no model available (session has no model and no pi-contemplator model configured)" };
|
|
353
|
+
// Authentication, provider composition, OAuth refresh, headers, and endpoint
|
|
354
|
+
// selection are deliberately resolved by ModelRegistry at request time.
|
|
355
|
+
return { ok: true, model };
|
|
352
356
|
}
|
|
353
357
|
|
|
354
358
|
setMemoryUpdateListener(listener: (ctx: MemoryUpdateCtx) => void): void {
|