@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 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.1.20",
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.85.1",
54
- "@earendil-works/pi-ai": "^0.85.1",
55
- "@earendil-works/pi-coding-agent": "^0.85.1",
56
- "@earendil-works/pi-server": "^0.85.1",
57
- "@earendil-works/pi-tui": "^0.85.1",
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 { MemoryUpdateCtx, Runtime } from "../../runtime.js";
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
- apiKey: string;
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
- apiKey: string;
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((message): message is AgentMessage => !!message && typeof message === "object"),
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
- session.history.push(data.message as AgentMessage);
598
- session.messageEntryIds.push(entry.id);
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
- shouldStopAfterTurn: () => intervention !== undefined && !finalActionWarned,
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 = { systemPrompt: buildContemplatorSystemPrompt(reviewerEnabled), messages: [...this.history, ...runMessages.slice(0, -1)], tools };
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 0.84.3 types provider-neutral choice as auto/none,
900
- // while individual provider APIs also support required/any. Preserve the
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, streamSimple);
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
- apiKey: reviewerModel.apiKey,
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>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
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 0.84+ interprets an explicit
1060
- // triggerTurn:false as "do not queue while streaming" and inserts directly
1061
- // into agent.state, outside the active run's context snapshot. Omitting it
1062
- // still does not start a turn while idle, but allows steer to work in-run.
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, apiKey, headers, sessionGeneration } = options;
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, apiKey, headers, sessionGeneration, key });
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, apiKey, headers, sessionGeneration, key } = options;
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, apiKey, headers,
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 must be omitted or Pi 0.84+ bypasses
1155
- // the streaming steer queue and the active run never sees this message.
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>, apiKey: resolved.apiKey, headers: resolved.headers, sessionGeneration: generation, history: this.reviewerSessions.get(request.id)?.history ?? [] });
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>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
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
- apiKey,
1291
- headers,
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
- apiKey: string;
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, apiKey, headers, priorSummaries = [], priorObservations, chunk, allowedSourceEntryIds, signal } = args;
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
- shouldStopAfterTurn: () => {
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
- systemPrompt: OBSERVER_SYSTEM,
227
- messages: history.slice(),
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, streamSimple);
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
- apiKey: string;
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: typeof streamSimple = ((model: Model<any>, context: any, options: any) => {
148
- const response = streamSimple(model, context, {
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 ReturnType<typeof streamSimple>;
164
- }) as typeof streamSimple;
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
- shouldStopAfterTurn: () => terminal !== undefined || remainingBudget() === 0,
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 = { systemPrompt: buildReviewerSystemPrompt(args.request.scope), messages: history.slice(), tools };
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
- history.push(...newMessages);
187
- args.onMessages?.(newMessages);
188
- const assistants = newMessages.filter((message): message is AgentMessage => message.role === "assistant");
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 shouldStopAfterTurn false so
200
- // the reviewer can search/recall and replace it. If the reviewer instead
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
- apiKey: string;
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 = { systemPrompt: SUMMARIZER_SYSTEM, messages: history.slice(), tools };
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
- shouldStopAfterTurn: () => completedWithDone || (effectiveMaxTurns !== undefined && ++turnCount >= effectiveMaxTurns),
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, streamSimple);
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 { ResolveResult, Runtime } from "../runtime.js";
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: any;
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
- apiKey: resolved.apiKey,
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
- apiKey: resolved.apiKey,
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; apiKey: string; headers?: Record<string, string> }
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: any;
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 observational-memory model configured)" };
346
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
347
- if (!auth.ok || !auth.apiKey) {
348
- const provider = (model as { provider?: string }).provider ?? "unknown";
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 {