@matthewfl/pi-contemplator 0.1.19 → 0.2.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 +2 -0
- package/package.json +10 -10
- package/src/agents/contemplator/agent.ts +39 -34
- package/src/agents/observer/agent.ts +7 -11
- package/src/agents/reviewer/agent.ts +15 -14
- package/src/agents/summarizer/agent.ts +4 -8
- package/src/hooks/consolidation-trigger.ts +5 -7
- package/src/runtime.ts +13 -9
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.86.0",
|
|
48
|
+
"@earendil-works/pi-ai": "^0.86.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
50
|
+
"@earendil-works/pi-tui": "^0.86.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.86.0",
|
|
54
|
+
"@earendil-works/pi-ai": "^0.86.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
56
|
+
"@earendil-works/pi-server": "^0.86.0",
|
|
57
|
+
"@earendil-works/pi-tui": "^0.86.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,8 +879,6 @@ 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",
|
|
@@ -890,18 +892,18 @@ export class Contemplator {
|
|
|
890
892
|
let nextPrompt = prompt;
|
|
891
893
|
for (let invocation = 1; invocation <= CONTEMPLATOR_MAX_INVOCATIONS && !intervention; invocation++) {
|
|
892
894
|
runMessages.push(nextPrompt);
|
|
893
|
-
const context: AgentContext = {
|
|
895
|
+
const context: AgentContext = { messages: [{ role: "system", content: buildContemplatorSystemPrompt(reviewerEnabled), toolsAdded: tools, timestamp: Date.now() }, ...this.history, ...runMessages.slice(0, -1)], tools };
|
|
894
896
|
const api = (resolved.model as Model<any>).api;
|
|
895
897
|
const invocationConfig: AgentLoopConfig & { onPayload?: (payload: unknown) => unknown } = invocation === 1 ? config : {
|
|
896
898
|
...config,
|
|
897
899
|
onPayload: (payload) => forceRequiredToolPayload(payload, api),
|
|
898
900
|
};
|
|
899
|
-
// SimpleStreamOptions
|
|
900
|
-
//
|
|
901
|
+
// Provider-neutral SimpleStreamOptions types choice as auto/none, while
|
|
902
|
+
// individual provider APIs also support required/any. Preserve the
|
|
901
903
|
// runtime hint and final-payload enforcement without weakening base types.
|
|
902
904
|
if (invocation > 1) (invocationConfig as any).toolChoice = requiredToolChoice(api);
|
|
903
905
|
workerWatchdog.progress();
|
|
904
|
-
const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal,
|
|
906
|
+
const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal, streamFn);
|
|
905
907
|
const result = await workerWatchdog.race((async () => {
|
|
906
908
|
for await (const event of stream) {
|
|
907
909
|
workerWatchdog!.progress();
|
|
@@ -966,8 +968,7 @@ export class Contemplator {
|
|
|
966
968
|
requestArgs: intervention.request,
|
|
967
969
|
branchEntries,
|
|
968
970
|
model: reviewerModel.model as Model<any>,
|
|
969
|
-
|
|
970
|
-
headers: reviewerModel.headers,
|
|
971
|
+
streamFn,
|
|
971
972
|
sessionGeneration,
|
|
972
973
|
});
|
|
973
974
|
}
|
|
@@ -975,7 +976,7 @@ export class Contemplator {
|
|
|
975
976
|
if (sessionGeneration === this.sessionGeneration) {
|
|
976
977
|
workerWatchdog.progress();
|
|
977
978
|
try {
|
|
978
|
-
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>,
|
|
979
|
+
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, streamFn, sessionGeneration, flushEpoch, workerWatchdog.signal));
|
|
979
980
|
} catch (compactionError) {
|
|
980
981
|
// The intervention and its durable messages are already complete. Private
|
|
981
982
|
// history maintenance must never relabel that successful work as failed.
|
|
@@ -1056,10 +1057,10 @@ export class Contemplator {
|
|
|
1056
1057
|
// designed for agents that run for hours; a probe must be injected after
|
|
1057
1058
|
// the current tool-call batch, before the very next model request. Never
|
|
1058
1059
|
// change this to nextTurn: that can postpone delivery until a user prompt.
|
|
1059
|
-
// IMPORTANT: omit triggerTurn entirely. Pi
|
|
1060
|
-
// triggerTurn
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1060
|
+
// IMPORTANT: omit triggerTurn entirely. Pi queues a streaming steer only
|
|
1061
|
+
// when triggerTurn is not explicitly false; false uses the deferred custom-
|
|
1062
|
+
// message path instead of steering the active run. When idle, omission merely
|
|
1063
|
+
// appends the message and does not manufacture a new agent turn.
|
|
1063
1064
|
// Whether Pi is currently running or idle, sendMessage owns this probe in an
|
|
1064
1065
|
// in-memory steer queue until message_end drains it. Track both cases so an
|
|
1065
1066
|
// unrelated observer update or compaction callback cannot restore and enqueue
|
|
@@ -1084,7 +1085,7 @@ export class Contemplator {
|
|
|
1084
1085
|
}
|
|
1085
1086
|
|
|
1086
1087
|
private queueStructuralReview(options: QueueStructuralReviewOptions): void {
|
|
1087
|
-
const { ctx, requestArgs, branchEntries, model,
|
|
1088
|
+
const { ctx, requestArgs, branchEntries, model, streamFn, sessionGeneration } = options;
|
|
1088
1089
|
const requestForKey: RequestReviewArgs = { scope: requestArgs.scope, evidence: requestArgs.evidence, concern: requestArgs.concern, review_focus: requestArgs.reviewFocus, constraints: requestArgs.constraints };
|
|
1089
1090
|
const key = reviewRequestKey(requestForKey);
|
|
1090
1091
|
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 +1096,11 @@ export class Contemplator {
|
|
|
1095
1096
|
const request: StructuralReviewRequest = { ...requestArgs, id: requestArgs.id, createdAt: Date.now(), requestedBy: "contemplator" };
|
|
1096
1097
|
this.pi.appendEntry(OM_REVIEW_REQUEST, { version: 1, request });
|
|
1097
1098
|
this.markTipPersisted(ctx);
|
|
1098
|
-
this.launchStructuralReview({ ctx, request, model,
|
|
1099
|
+
this.launchStructuralReview({ ctx, request, model, streamFn, sessionGeneration, key });
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1101
1102
|
private launchStructuralReview(options: LaunchStructuralReviewOptions): boolean {
|
|
1102
|
-
const { ctx, request, model,
|
|
1103
|
+
const { ctx, request, model, streamFn, sessionGeneration, key } = options;
|
|
1103
1104
|
if (this.runtime.reviewInFlight || this.inFlightReviewIds.has(request.id)) return false;
|
|
1104
1105
|
// Do not spin a no-progress reviewer repeatedly in one live session. The
|
|
1105
1106
|
// request stays pending and a later session/tree restoration resumes it.
|
|
@@ -1114,7 +1115,7 @@ export class Contemplator {
|
|
|
1114
1115
|
try {
|
|
1115
1116
|
debugLog("reviewer.started", { reviewRequestId: request.id, scope: request.scope, resumed: session.history.length > 0 });
|
|
1116
1117
|
const result = await watchdog.race(runStructuralReview({
|
|
1117
|
-
request, model,
|
|
1118
|
+
request, model, streamFn,
|
|
1118
1119
|
signal: watchdog.signal,
|
|
1119
1120
|
onProgress: watchdog.progress,
|
|
1120
1121
|
getBranch: () => ctx.sessionManager.getBranch() as Entry[],
|
|
@@ -1124,6 +1125,7 @@ export class Contemplator {
|
|
|
1124
1125
|
watchdog.progress();
|
|
1125
1126
|
if (!acceptsMessages || sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
|
|
1126
1127
|
for (const message of messages) {
|
|
1128
|
+
if (!isResumableReviewerMessage(message)) continue;
|
|
1127
1129
|
session.history.push(message);
|
|
1128
1130
|
const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
|
|
1129
1131
|
if (entryId) {
|
|
@@ -1150,9 +1152,9 @@ export class Contemplator {
|
|
|
1150
1152
|
this.markTipPersisted(ctx);
|
|
1151
1153
|
debugLog(result.outcome === "proposal" ? "reviewer.proposal_created" : "reviewer.no_proposal", { reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope });
|
|
1152
1154
|
if (result.outcome === "proposal") {
|
|
1153
|
-
const notice = `BACKGROUND ${result.scope.toUpperCase()} REVIEW PROPOSAL [${result.id}]\n\
|
|
1154
|
-
// As with probes, triggerTurn
|
|
1155
|
-
//
|
|
1155
|
+
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.`;
|
|
1156
|
+
// As with probes, omit triggerTurn so a streaming run receives this
|
|
1157
|
+
// through its steer queue without manufacturing a turn while idle.
|
|
1156
1158
|
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
1159
|
this.pi.appendEntry(OM_REVIEWER_NOTICE, { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope, content: notice });
|
|
1158
1160
|
this.markTipPersisted(ctx);
|
|
@@ -1216,7 +1218,7 @@ export class Contemplator {
|
|
|
1216
1218
|
debugLog("reviewer.resume_skipped", { reviewRequestId: request.id, reason: resolved.ok ? "session_changed" : resolved.reason });
|
|
1217
1219
|
return;
|
|
1218
1220
|
}
|
|
1219
|
-
this.launchStructuralReview({ ctx, request, model: resolved.model as Model<any>,
|
|
1221
|
+
this.launchStructuralReview({ ctx, request, model: resolved.model as Model<any>, streamFn: modelRegistryStream(ctx.modelRegistry), sessionGeneration: generation, history: this.reviewerSessions.get(request.id)?.history ?? [] });
|
|
1220
1222
|
} finally {
|
|
1221
1223
|
this.resolvingReviewIds.delete(request.id);
|
|
1222
1224
|
}
|
|
@@ -1263,7 +1265,7 @@ export class Contemplator {
|
|
|
1263
1265
|
return own?.id;
|
|
1264
1266
|
}
|
|
1265
1267
|
|
|
1266
|
-
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>,
|
|
1268
|
+
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, streamFn: StreamFn, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
|
|
1267
1269
|
const history = this.history.slice();
|
|
1268
1270
|
const historyEntryIds = this.historyEntryIds.slice();
|
|
1269
1271
|
const historyTokens = contemplatorHistoryTokens(history);
|
|
@@ -1287,10 +1289,13 @@ export class Contemplator {
|
|
|
1287
1289
|
history.slice(0, prefixEnd),
|
|
1288
1290
|
model,
|
|
1289
1291
|
CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
+
undefined,
|
|
1293
|
+
undefined,
|
|
1292
1294
|
signal,
|
|
1293
1295
|
CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
|
|
1296
|
+
undefined,
|
|
1297
|
+
undefined,
|
|
1298
|
+
streamFn,
|
|
1294
1299
|
);
|
|
1295
1300
|
|
|
1296
1301
|
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,8 +203,6 @@ 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",
|
|
@@ -222,10 +218,10 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
|
|
|
222
218
|
let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
|
|
223
219
|
let lengthAttempts = 0;
|
|
224
220
|
const runInvocation = async (prompt: Message, afterLength = false): Promise<void> => {
|
|
221
|
+
const tools = [recordObservations as AgentTool<any>, doneTool];
|
|
225
222
|
const context: AgentContext = {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
tools: [recordObservations as AgentTool<any>, doneTool],
|
|
223
|
+
messages: [{ role: "system", content: OBSERVER_SYSTEM, toolsAdded: tools, timestamp: Date.now() }, ...history],
|
|
224
|
+
tools,
|
|
229
225
|
};
|
|
230
226
|
// Publish a live launch-local transcript for /om:view observer. Keep an
|
|
231
227
|
// invocation-local list because agentLoop owns its internal context copy.
|
|
@@ -234,7 +230,7 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
|
|
|
234
230
|
const invocationConfig: AgentLoopConfig = afterLength && reasoning
|
|
235
231
|
? { ...baseConfig, reasoning: "minimal" }
|
|
236
232
|
: baseConfig;
|
|
237
|
-
const stream = loop([prompt], context, invocationConfig, signal,
|
|
233
|
+
const stream = loop([prompt], context, invocationConfig, signal, args.streamFn);
|
|
238
234
|
for await (const event of stream) {
|
|
239
235
|
args.onProgress?.();
|
|
240
236
|
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,12 +158,10 @@ 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",
|
|
@@ -174,7 +170,7 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
174
170
|
const promptMessage = prompt as AgentMessage;
|
|
175
171
|
// agentLoop receives the new prompt separately. Its context must therefore
|
|
176
172
|
// contain only prior messages, otherwise a resumed prompt is sent twice.
|
|
177
|
-
const context: AgentContext = {
|
|
173
|
+
const context: AgentContext = { messages: [{ role: "system", content: buildReviewerSystemPrompt(args.request.scope), toolsAdded: tools, timestamp: Date.now() }, ...history], tools };
|
|
178
174
|
history.push(promptMessage);
|
|
179
175
|
args.onMessages?.([promptMessage]);
|
|
180
176
|
const stream = loop([prompt], context, config, args.signal, budgetedStreamSimple);
|
|
@@ -183,9 +179,14 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
|
|
|
183
179
|
logAgentStreamError("reviewer", event);
|
|
184
180
|
}
|
|
185
181
|
const newMessages = await stream.result();
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
// agentLoop returns the supplied prompt in its per-invocation result. It is
|
|
183
|
+
// already persisted above, so exclude that exact input alongside any
|
|
184
|
+
// synthesized tool-declaration system messages. declareToolChanges preserves
|
|
185
|
+
// non-system prompt object identity even when it inserts a system delta.
|
|
186
|
+
const resumableMessages = newMessages.filter((message) => message !== promptMessage && message.role !== "system");
|
|
187
|
+
history.push(...resumableMessages);
|
|
188
|
+
args.onMessages?.(resumableMessages);
|
|
189
|
+
const assistants = resumableMessages.filter((message) => message.role === "assistant");
|
|
189
190
|
if (args.recordUsage) {
|
|
190
191
|
for (const message of assistants) {
|
|
191
192
|
const usage = (message as { usage?: LlmUsageInput }).usage;
|
|
@@ -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",
|
|
@@ -560,7 +556,7 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
|
|
|
560
556
|
if (requireToolCall) (config as any).toolChoice = requiredToolChoice(args.model.api);
|
|
561
557
|
history.push(prompt as AgentMessage);
|
|
562
558
|
args.onMessages?.(history.slice());
|
|
563
|
-
const stream = loop([prompt], context, config, args.signal,
|
|
559
|
+
const stream = loop([prompt], context, config, args.signal, args.streamFn);
|
|
564
560
|
// agentLoop emits message_start/message_end for the supplied prompt, so
|
|
565
561
|
// seed live checkpoints without our already-pushed copy of that prompt.
|
|
566
562
|
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 {
|