@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 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.2.0 requires **Pi 0.86.x**.
161
+
160
162
  Install the published package through Pi:
161
163
 
162
164
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.1.19",
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.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.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 { 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,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 = { systemPrompt: buildContemplatorSystemPrompt(reviewerEnabled), messages: [...this.history, ...runMessages.slice(0, -1)], tools };
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 0.84.3 types provider-neutral choice as auto/none,
900
- // while individual provider APIs also support required/any. Preserve the
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, streamSimple);
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
- apiKey: reviewerModel.apiKey,
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>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
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 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.
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, apiKey, headers, sessionGeneration } = options;
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, apiKey, headers, sessionGeneration, key });
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, apiKey, headers, sessionGeneration, key } = options;
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, apiKey, headers,
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\n${result.summary}\n\nRecall memory [${result.id}] to read the full conceptual proposal when it is relevant.\n\nThis is advisory. Evaluate it 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.
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>, apiKey: resolved.apiKey, headers: resolved.headers, sessionGeneration: generation, history: this.reviewerSessions.get(request.id)?.history ?? [] });
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>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
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
- apiKey,
1291
- headers,
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
- 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,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
- systemPrompt: OBSERVER_SYSTEM,
227
- messages: history.slice(),
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, streamSimple);
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
- 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,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 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",
@@ -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 = { systemPrompt: buildReviewerSystemPrompt(args.request.scope), messages: history.slice(), tools };
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
- history.push(...newMessages);
187
- args.onMessages?.(newMessages);
188
- const assistants = newMessages.filter((message): message is AgentMessage => message.role === "assistant");
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
- 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",
@@ -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, streamSimple);
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 { 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 {