@oh-my-pi/pi-agent-core 16.1.22 → 16.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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.0] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added an optional `cwdResolver` to `Agent` and `getCwd` to `AgentLoopConfig` to dynamically resolve the working directory per LLM call, allowing workspace-scoped provider discovery (such as GitLab Duo Agent) to follow live directory changes without reconstructing the agent.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue where API-level provider refusals were replayed as assistant dialogue on subsequent requests, preventing repeated refusals after a single blocked turn.
14
+ - Fixed a bug where internal streaming state (`partialJson`) could leak onto the final `AssistantMessage` if a stream ended without a `toolcall_end` event.
15
+ - Fixed `Agent` to correctly forward the working directory (`cwd`) into provider stream options, enabling providers like GitLab Duo Agent to scope local tool execution to the workspace.
16
+ - Enabled custom OpenAI-compatible providers to use native remote compaction instead of falling back to local summarization.
17
+
18
+ ## [16.1.23] - 2026-06-26
19
+
20
+ ### Changed
21
+
22
+ - Changed `AgentLoopConfig.onTurnEnd` and `Agent.setOnTurnEnd` callbacks to receive whether the loop will continue with another provider request.
23
+
24
+ ### Fixed
25
+
26
+ - Fixed stale snapcompact archive frames leaking into context-full compaction after `compaction.strategy` was switched from `snapcompact` to `context-full`. Switching strategy left the latest compaction entry's `preserveData.snapcompact` in place, so context-full kept rebuilding context with old image frames attached — inflating context/token usage and making sessions appear to compact early (around ~60% apparent window use). The first context-full compaction after the switch now folds the prior archive's plaintext into the LLM summary input and strips `preserveData.snapcompact` from the new entry; legacy frame-only archives (no plaintext to migrate) are stripped outright. ([#3561](https://github.com/can1357/oh-my-pi/pull/3561) by [@serverinspector](https://github.com/serverinspector))
27
+
5
28
  ## [16.1.18] - 2026-06-25
6
29
 
7
30
  ### Fixed
@@ -2,7 +2,7 @@ import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Co
2
2
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
5
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types";
5
+ import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types";
6
6
  export declare class AgentBusyError extends Error {
7
7
  constructor(message?: string);
8
8
  }
@@ -161,6 +161,17 @@ export interface AgentOptions {
161
161
  * Cursor tool result callback for exec tool responses.
162
162
  */
163
163
  cursorOnToolResult?: CursorToolResultHandler;
164
+ /** Current working directory used by local tool execution. */
165
+ cwd?: string;
166
+ /**
167
+ * Resolver for the live working directory, re-read on every turn. When set, it
168
+ * overrides the static {@link cwd} at config-build time so a session move
169
+ * (`/move`, which updates the host's cwd without reconstructing the Agent) is
170
+ * reflected in provider options — e.g. GitLab Duo Agent namespace/project
171
+ * discovery keys off this cwd's git remote. Falls back to `cwd` when it returns
172
+ * `undefined`.
173
+ */
174
+ cwdResolver?: () => string | undefined;
164
175
  /**
165
176
  * Called after a tool call has been validated and is about to execute.
166
177
  * See {@link AgentLoopConfig.beforeToolCall} for full semantics.
@@ -341,7 +352,7 @@ export declare class Agent {
341
352
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
342
353
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
343
354
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
344
- setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void;
355
+ setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
345
356
  /**
346
357
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
347
358
  * completions, late LSP diagnostics) drained at each step boundary. Never
@@ -33,6 +33,7 @@ export interface CompactionSettings {
33
33
  strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
34
34
  thresholdPercent?: number;
35
35
  thresholdTokens?: number;
36
+ midTurnEnabled?: boolean;
36
37
  reserveTokens: number;
37
38
  keepRecentTokens: number;
38
39
  autoContinue?: boolean;
@@ -3,6 +3,7 @@ export * from "./agent-loop";
3
3
  export * from "./append-only-context";
4
4
  export * from "./compaction";
5
5
  export * from "./proxy";
6
+ export * from "./replay-policy";
6
7
  export * from "./run-collector";
7
8
  export * from "./telemetry";
8
9
  export * from "./thinking";
@@ -0,0 +1,5 @@
1
+ import type { AssistantMessage, Message } from "@oh-my-pi/pi-ai";
2
+ /** Detects API-level provider refusals that are terminal errors, not dialogue to replay. */
3
+ export declare function isProviderRefusalMessage(message: AssistantMessage): boolean;
4
+ /** Removes API-level provider refusals from live provider replay while preserving other messages. */
5
+ export declare function filterProviderReplayMessages(messages: readonly Message[]): Message[];
@@ -13,6 +13,14 @@ export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMe
13
13
  * (e.g. dropping late diagnostics a newer edit superseded).
14
14
  */
15
15
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
16
+ export interface AgentTurnEndContext {
17
+ /** Assistant/user message that just completed this turn boundary. */
18
+ message: AgentMessage;
19
+ /** Tool results produced by this turn, already paired with `message` in the live context. */
20
+ toolResults: ToolResultMessage[];
21
+ /** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
22
+ willContinue: boolean;
23
+ }
16
24
  /**
17
25
  * A soft tool requirement: the host wants `toolName` called before the loop
18
26
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -276,6 +284,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
276
284
  * serving path while leaving the OpenAI/Anthropic tier untouched).
277
285
  */
278
286
  getServiceTier?: (model: Model) => ServiceTier | undefined;
287
+ /**
288
+ * Per-call working-directory resolver, read once per LLM call. When set, its
289
+ * return value overrides the static {@link SimpleStreamOptions.cwd} for the
290
+ * request (falling back to that static `cwd` when it returns `undefined`).
291
+ * Lets the host reflect a session move (`/move`, which updates the working
292
+ * directory without reconstructing the loop config) into provider options —
293
+ * e.g. GitLab Duo Agent namespace/project discovery keys off this cwd's git
294
+ * remote, so a stale value would strand discovery on the original repo.
295
+ */
296
+ getCwd?: () => string | undefined;
279
297
  /**
280
298
  * Called after a tool call has been validated and is about to execute.
281
299
  *
@@ -292,11 +310,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
292
310
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
293
311
  /**
294
312
  * Called after a turn ends and before the loop polls steering/asides for the
295
- * next iteration. Use this for awaited per-turn bookkeeping that must be
296
- * visible before the next model request (e.g. synchronizing an advisor's
297
- * backlog so advice produced during the wait is injected as an aside).
313
+ * next iteration. `context` carries the just-finished turn; `context.willContinue`
314
+ * is true when the current tool-loop batch is continuing without yielding to
315
+ * post-turn steering.
298
316
  */
299
- onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
317
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
300
318
  /**
301
319
  * Called once an assistant message is finalized from the model stream, before
302
320
  * it is appended to the context, emitted as `message_end`, or its tool calls
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "16.1.22",
4
+ "version": "16.2.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.1.22",
39
- "@oh-my-pi/pi-catalog": "16.1.22",
40
- "@oh-my-pi/pi-natives": "16.1.22",
41
- "@oh-my-pi/pi-utils": "16.1.22",
42
- "@oh-my-pi/pi-wire": "16.1.22",
43
- "@oh-my-pi/snapcompact": "16.1.22",
38
+ "@oh-my-pi/pi-ai": "16.2.0",
39
+ "@oh-my-pi/pi-catalog": "16.2.0",
40
+ "@oh-my-pi/pi-natives": "16.2.0",
41
+ "@oh-my-pi/pi-utils": "16.2.0",
42
+ "@oh-my-pi/pi-wire": "16.2.0",
43
+ "@oh-my-pi/snapcompact": "16.2.0",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  AgentMessage,
63
63
  AgentTool,
64
64
  AgentToolResult,
65
+ AgentTurnEndContext,
65
66
  AsideMessage,
66
67
  StreamFn,
67
68
  } from "./types";
@@ -406,12 +407,13 @@ async function emitTurnEnd(
406
407
  toolResults: ToolResultMessage[],
407
408
  config: AgentLoopConfig,
408
409
  signal?: AbortSignal,
410
+ context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
409
411
  ): Promise<void> {
410
412
  stream.push({ type: "turn_end", message, toolResults });
411
413
  const isAbortedOrError =
412
414
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
413
415
  if (signal?.aborted || isAbortedOrError) return;
414
- await config.onTurnEnd?.(currentContext.messages, signal);
416
+ await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
415
417
  }
416
418
 
417
419
  /**
@@ -903,7 +905,7 @@ async function runLoopBody(
903
905
  status: message.stopReason === "aborted" ? "aborted" : "error",
904
906
  });
905
907
  }
906
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
908
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
907
909
 
908
910
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
909
911
  stream.end(newMessages);
@@ -1032,7 +1034,9 @@ async function runLoopBody(
1032
1034
  hasMoreToolCalls = true;
1033
1035
  }
1034
1036
 
1035
- await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
1037
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1038
+ willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1039
+ });
1036
1040
 
1037
1041
  if (isDeadlineExceeded(config.deadline)) {
1038
1042
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
@@ -1221,6 +1225,9 @@ async function streamAssistantResponse(
1221
1225
  const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1222
1226
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1223
1227
  const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1228
+ // `getCwd` is read once per LLM call so a mid-run session move (`/move`) reaches
1229
+ // workspace-scoped provider discovery; falls back to the static `cwd` when unset.
1230
+ const effectiveCwd = config.getCwd?.() ?? config.cwd;
1224
1231
 
1225
1232
  const chatStepNumber = stepCounter.count;
1226
1233
  stepCounter.count += 1;
@@ -1272,6 +1279,7 @@ async function streamAssistantResponse(
1272
1279
  disableReasoning: effectiveDisableReasoning,
1273
1280
  temperature: effectiveTemperature,
1274
1281
  serviceTier: effectiveServiceTier,
1282
+ cwd: effectiveCwd,
1275
1283
  signal: finalRequestSignal,
1276
1284
  onResponse: captureOnResponse,
1277
1285
  });
package/src/agent.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  resolveOwnedDialectFromEnv,
37
37
  } from "./agent-loop";
38
38
  import type { AppendOnlyContextManager } from "./append-only-context";
39
+ import { isProviderRefusalMessage } from "./replay-policy";
39
40
  import type {
40
41
  AgentContext,
41
42
  AgentEvent,
@@ -44,6 +45,7 @@ import type {
44
45
  AgentState,
45
46
  AgentTool,
46
47
  AgentToolContext,
48
+ AgentTurnEndContext,
47
49
  AsideMessage,
48
50
  StreamFn,
49
51
  ToolCallContext,
@@ -53,10 +55,13 @@ import { isSoftToolRequirement } from "./types";
53
55
  import { EventLoopKeepalive } from "./utils/yield";
54
56
 
55
57
  /**
56
- * Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
58
+ * Default convertToLlm: Keep only LLM-compatible replay messages.
57
59
  */
58
60
  function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
59
- return messages.filter((m): m is Message => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
61
+ return messages.filter((m): m is Message => {
62
+ if (m.role === "assistant") return !isProviderRefusalMessage(m);
63
+ return m.role === "user" || m.role === "toolResult";
64
+ });
60
65
  }
61
66
 
62
67
  const ANTHROPIC_OUTPUT_BLOCKED_PREFIX = "Output blocked by conten";
@@ -268,6 +273,17 @@ export interface AgentOptions {
268
273
  */
269
274
  cursorOnToolResult?: CursorToolResultHandler;
270
275
 
276
+ /** Current working directory used by local tool execution. */
277
+ cwd?: string;
278
+ /**
279
+ * Resolver for the live working directory, re-read on every turn. When set, it
280
+ * overrides the static {@link cwd} at config-build time so a session move
281
+ * (`/move`, which updates the host's cwd without reconstructing the Agent) is
282
+ * reflected in provider options — e.g. GitLab Duo Agent namespace/project
283
+ * discovery keys off this cwd's git remote. Falls back to `cwd` when it returns
284
+ * `undefined`.
285
+ */
286
+ cwdResolver?: () => string | undefined;
271
287
  /**
272
288
  * Called after a tool call has been validated and is about to execute.
273
289
  * See {@link AgentLoopConfig.beforeToolCall} for full semantics.
@@ -354,6 +370,9 @@ export class Agent {
354
370
  #getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
355
371
  #cursorExecHandlers?: CursorExecHandlers;
356
372
  #cursorOnToolResult?: CursorToolResultHandler;
373
+ #cwd?: string;
374
+ #cwdResolver?: () => string | undefined;
375
+
357
376
  #runningPrompt?: Promise<void>;
358
377
  #resolveRunningPrompt?: () => void;
359
378
  #kimiApiFormat?: "openai" | "anthropic";
@@ -370,7 +389,7 @@ export class Agent {
370
389
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
371
390
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
372
391
  #onBeforeYield?: () => Promise<void> | void;
373
- #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
392
+ #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
374
393
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
375
394
  #telemetry?: AgentLoopConfig["telemetry"];
376
395
  #appendOnlyContext?: AppendOnlyContextManager;
@@ -429,6 +448,8 @@ export class Agent {
429
448
  this.#getToolContext = opts.getToolContext;
430
449
  this.#cursorExecHandlers = opts.cursorExecHandlers;
431
450
  this.#cursorOnToolResult = opts.cursorOnToolResult;
451
+ this.#cwd = opts.cwd;
452
+ this.#cwdResolver = opts.cwdResolver;
432
453
  this.#kimiApiFormat = opts.kimiApiFormat;
433
454
  this.#preferWebsockets = opts.preferWebsockets;
434
455
  this.#transformToolCallArguments = opts.transformToolCallArguments;
@@ -726,7 +747,11 @@ export class Agent {
726
747
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void {
727
748
  this.#onBeforeYield = fn;
728
749
  }
729
- setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void {
750
+ setOnTurnEnd(
751
+ fn:
752
+ | ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void)
753
+ | undefined,
754
+ ): void {
730
755
  this.#onTurnEnd = fn;
731
756
  }
732
757
 
@@ -1129,6 +1154,8 @@ export class Agent {
1129
1154
  },
1130
1155
  cursorExecHandlers: this.#cursorExecHandlers,
1131
1156
  cursorOnToolResult,
1157
+ cwd: this.#cwd,
1158
+ getCwd: this.#cwdResolver,
1132
1159
  transformToolCallArguments: this.#transformToolCallArguments,
1133
1160
  intentTracing: this.#intentTracing,
1134
1161
  pruneToolDescriptions: this.#pruneToolDescriptions,
@@ -1142,7 +1169,7 @@ export class Agent {
1142
1169
  : undefined,
1143
1170
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1144
1171
  onHarmonyLeak: this.#onHarmonyLeak,
1145
- onTurnEnd: (messages, signal) => this.#onTurnEnd?.(messages, signal),
1172
+ onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1146
1173
  getToolChoice,
1147
1174
  getReasoning: () => this.#state.thinkingLevel,
1148
1175
  getDisableReasoning: () => this.#state.disableReasoning,
@@ -44,6 +44,7 @@ import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { typ
44
44
  import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
45
45
  import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
46
46
  import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" };
47
+ import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
47
48
 
48
49
  import {
49
50
  computeFileLists,
@@ -148,6 +149,7 @@ export interface CompactionSettings {
148
149
  strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
149
150
  thresholdPercent?: number;
150
151
  thresholdTokens?: number;
152
+ midTurnEnabled?: boolean;
151
153
  reserveTokens: number;
152
154
  keepRecentTokens: number;
153
155
  autoContinue?: boolean;
@@ -160,6 +162,7 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
160
162
  strategy: "context-full",
161
163
  thresholdPercent: -1,
162
164
  thresholdTokens: -1,
165
+ midTurnEnabled: true,
163
166
  reserveTokens: 16384,
164
167
  keepRecentTokens: 20000,
165
168
  autoContinue: true,
@@ -658,6 +661,27 @@ export interface SummaryOptions {
658
661
  fetch?: FetchImpl;
659
662
  }
660
663
 
664
+ function formatPreviousSnapcompactArchive(archiveText: string): string {
665
+ return prompt.render(snapcompactArchiveContextPrompt, { archiveText });
666
+ }
667
+
668
+ function mergePreviousSummaryWithSnapcompactArchive(
669
+ previousSummary: string | undefined,
670
+ archiveText: string | undefined,
671
+ ): string | undefined {
672
+ if (!archiveText) return previousSummary;
673
+ const archiveSummary = formatPreviousSnapcompactArchive(archiveText);
674
+ return previousSummary ? `${previousSummary}\n\n${archiveSummary}` : archiveSummary;
675
+ }
676
+
677
+ function createSnapcompactArchiveMigrationMessage(archiveText: string): Message {
678
+ return {
679
+ role: "user",
680
+ content: [{ type: "text", text: formatPreviousSnapcompactArchive(archiveText) }],
681
+ timestamp: Date.now(),
682
+ };
683
+ }
684
+
661
685
  export async function generateSummary(
662
686
  currentMessages: AgentMessage[],
663
687
  model: Model,
@@ -1101,10 +1125,27 @@ export async function compact(
1101
1125
  fetch: options?.fetch,
1102
1126
  };
1103
1127
 
1128
+ const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1129
+ const previousSnapcompactArchiveText = previousSnapcompactArchive
1130
+ ? snapcompact.archiveSourceText(previousSnapcompactArchive)
1131
+ : undefined;
1132
+ const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
1133
+ previousSummary,
1134
+ previousSnapcompactArchiveText,
1135
+ );
1136
+ const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
1137
+ ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1138
+ : undefined;
1139
+
1104
1140
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1105
1141
  if (settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1106
1142
  const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1107
- const remoteMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1143
+ const remoteMessages: AgentMessage[] = [
1144
+ ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1145
+ ...messagesToSummarize,
1146
+ ...turnPrefixMessages,
1147
+ ...recentMessages,
1148
+ ];
1108
1149
  const previousReplacementHistory =
1109
1150
  previousRemoteCompaction?.provider === model.provider
1110
1151
  ? previousRemoteCompaction.replacementHistory
@@ -1150,7 +1191,7 @@ export async function compact(
1150
1191
  if (isSplitTurn && turnPrefixMessages.length > 0) {
1151
1192
  // Generate both summaries in parallel
1152
1193
  const [historyResult, turnPrefixResult] = await Promise.all([
1153
- messagesToSummarize.length > 0
1194
+ messagesToSummarize.length > 0 || previousSummaryForCompaction
1154
1195
  ? generateSummary(
1155
1196
  messagesToSummarize,
1156
1197
  model,
@@ -1158,7 +1199,7 @@ export async function compact(
1158
1199
  apiKey,
1159
1200
  signal,
1160
1201
  customInstructions,
1161
- previousSummary,
1202
+ previousSummaryForCompaction,
1162
1203
  summaryOptions,
1163
1204
  )
1164
1205
  : Promise.resolve("No prior history."),
@@ -1175,12 +1216,12 @@ export async function compact(
1175
1216
  apiKey,
1176
1217
  signal,
1177
1218
  customInstructions,
1178
- previousSummary,
1219
+ previousSummaryForCompaction,
1179
1220
  summaryOptions,
1180
1221
  );
1181
- } else if (previousSummary) {
1222
+ } else if (previousSummaryForCompaction) {
1182
1223
  // No new messages to summarize, preserve previous summary
1183
- summary = previousSummary;
1224
+ summary = previousSummaryForCompaction;
1184
1225
  } else {
1185
1226
  // No messages and no previous summary
1186
1227
  summary = "No prior history.";
@@ -1214,13 +1255,21 @@ export async function compact(
1214
1255
  throw new Error("First kept entry has no ID - session may need migration");
1215
1256
  }
1216
1257
 
1258
+ // This LLM-summary path migrated any prior snapcompact frames into the summary
1259
+ // text above; strip the now-stale frame archive from preserveData so it cannot
1260
+ // re-attach to the rebuilt context. Only the legacy-frame case needs stripping —
1261
+ // when there was no previous archive, preserveData carries no frames to drop.
1262
+ const finalPreserveData = previousSnapcompactArchive
1263
+ ? snapcompact.stripPreservedArchive(preserveData)
1264
+ : preserveData;
1265
+
1217
1266
  return {
1218
1267
  summary,
1219
1268
  shortSummary,
1220
1269
  firstKeptEntryId,
1221
1270
  tokensBefore,
1222
1271
  details: { readFiles, modifiedFiles } as CompactionDetails,
1223
- preserveData,
1272
+ preserveData: finalPreserveData,
1224
1273
  };
1225
1274
  }
1226
1275
 
@@ -1,5 +1,4 @@
1
1
  import type {
2
- AssistantMessage,
3
2
  ImageContent,
4
3
  Message,
5
4
  MessageAttribution,
@@ -214,7 +213,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
214
213
  case "developer":
215
214
  return { ...message, attribution: message.attribution ?? "agent" };
216
215
  case "assistant":
217
- return message as AssistantMessage;
216
+ return message;
218
217
  case "toolResult":
219
218
  return {
220
219
  ...message,
@@ -13,9 +13,9 @@
13
13
  */
14
14
 
15
15
  import { ProviderHttpError } from "@oh-my-pi/pi-ai/errors";
16
- import { parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
16
+ import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
17
17
  import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
18
- import type { AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
18
+ import type { Api, AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
19
19
  import {
20
20
  getOpenAIResponsesHistoryItems,
21
21
  getOpenAIResponsesHistoryPayload,
@@ -27,7 +27,7 @@ import {
27
27
  OPENAI_HEADER_VALUES,
28
28
  OPENAI_HEADERS,
29
29
  } from "@oh-my-pi/pi-catalog/wire/codex";
30
- import { logger } from "@oh-my-pi/pi-utils";
30
+ import { $env, logger } from "@oh-my-pi/pi-utils";
31
31
 
32
32
  // ============================================================================
33
33
  // Public types
@@ -45,6 +45,8 @@ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
45
45
  */
46
46
  export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
47
47
 
48
+ const DEFAULT_AZURE_API_VERSION = "v1";
49
+
48
50
  /** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
49
51
  function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
50
52
  if (timeoutMs <= 0) return signal;
@@ -86,12 +88,25 @@ export interface RemoteCompactionResponse {
86
88
  // OpenAI provider gating + endpoint resolution
87
89
  // ============================================================================
88
90
 
91
+ function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
92
+ return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
93
+ }
94
+
89
95
  export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
90
- return model.provider === "openai" || model.provider === "openai-codex";
96
+ if (model.remoteCompaction?.enabled === false) return false;
97
+ if (model.provider === "openai" || model.provider === "openai-codex") return true;
98
+ if (model.remoteCompaction?.enabled !== true) return false;
99
+ return isOpenAiRemoteCompactionApi(model.remoteCompaction.api ?? model.api);
91
100
  }
92
101
 
93
102
  function resolveOpenAiCompactEndpoint(model: Model): string {
94
- if (model.provider === "openai-codex") {
103
+ const configuredEndpoint = model.remoteCompaction?.endpoint;
104
+ const compactionApi = model.remoteCompaction?.api ?? model.api;
105
+ if (compactionApi === "azure-openai-responses") {
106
+ return resolveAzureOpenAiCompactEndpoint(model, configuredEndpoint);
107
+ }
108
+ if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
109
+ if (model.provider === "openai-codex" || compactionApi === "openai-codex-responses") {
95
110
  return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
96
111
  }
97
112
 
@@ -102,6 +117,41 @@ function resolveOpenAiCompactEndpoint(model: Model): string {
102
117
  return `${normalizedBase}/v1/responses/compact`;
103
118
  }
104
119
 
120
+ function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: string | undefined): string {
121
+ const endpoint =
122
+ configuredEndpoint && configuredEndpoint.length > 0
123
+ ? configuredEndpoint
124
+ : `${resolveAzureOpenAiBaseUrl(model)}/responses/compact`;
125
+ return appendAzureApiVersion(endpoint);
126
+ }
127
+
128
+ function resolveAzureOpenAiBaseUrl(model: Model): string {
129
+ const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
130
+ const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
131
+ const resolvedBaseUrl =
132
+ baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
133
+ if (!resolvedBaseUrl) {
134
+ throw new Error(
135
+ "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
136
+ );
137
+ }
138
+ return resolvedBaseUrl.replace(/\/+$/, "");
139
+ }
140
+
141
+ function appendAzureApiVersion(endpoint: string): string {
142
+ if (/[?&]api-version=/.test(endpoint)) return endpoint;
143
+ const separator = endpoint.includes("?") ? "&" : "?";
144
+ return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
145
+ }
146
+
147
+ function resolveOpenAiCompactModel(model: Model): string {
148
+ const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
149
+ const compactionApi = model.remoteCompaction?.api ?? model.api;
150
+ if (compactionApi !== "azure-openai-responses") return requestModel;
151
+ const mappedDeployment = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(requestModel);
152
+ return mappedDeployment ?? requestModel;
153
+ }
154
+
105
155
  function resolveOpenAiCodexCompactEndpoint(baseUrl: string | undefined): string {
106
156
  const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : CODEX_BASE_URL;
107
157
  const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
@@ -444,7 +494,6 @@ export function buildOpenAiNativeHistory(
444
494
  // ============================================================================
445
495
  // Endpoint requests
446
496
  // ============================================================================
447
-
448
497
  export async function requestOpenAiRemoteCompaction(
449
498
  model: Model,
450
499
  apiKey: string,
@@ -454,16 +503,24 @@ export async function requestOpenAiRemoteCompaction(
454
503
  opts?: { fetch?: FetchImpl; timeoutMs?: number },
455
504
  ): Promise<OpenAiRemoteCompactionResponse> {
456
505
  const endpoint = resolveOpenAiCompactEndpoint(model);
506
+ const requestModel = resolveOpenAiCompactModel(model);
457
507
  const request: OpenAiRemoteCompactionRequest = {
458
- model: model.id,
508
+ model: requestModel,
459
509
  input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
460
510
  instructions,
461
511
  };
462
- const headers: Record<string, string> = {
463
- "content-type": "application/json",
464
- Authorization: `Bearer ${apiKey}`,
465
- ...(model.headers ?? {}),
466
- };
512
+ const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
513
+ const headers: Record<string, string> = isAzureOpenAiResponses
514
+ ? {
515
+ "content-type": "application/json",
516
+ "api-key": apiKey,
517
+ ...(model.headers ?? {}),
518
+ }
519
+ : {
520
+ "content-type": "application/json",
521
+ Authorization: `Bearer ${apiKey}`,
522
+ ...(model.headers ?? {}),
523
+ };
467
524
 
468
525
  // Codex endpoints require additional auth headers
469
526
  if (model.provider === "openai-codex") {
@@ -0,0 +1,3 @@
1
+ Previous snapcompact archive source text:
2
+
3
+ {{archiveText}}
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export * from "./append-only-context";
8
8
  export * from "./compaction";
9
9
  // Proxy utilities
10
10
  export * from "./proxy";
11
+ // Replay policy
12
+ export * from "./replay-policy";
11
13
  // Run-level telemetry collector + aggregators
12
14
  export * from "./run-collector";
13
15
  // Telemetry
package/src/proxy.ts CHANGED
@@ -13,9 +13,8 @@ import {
13
13
  type StopReason,
14
14
  type ToolCall,
15
15
  } from "@oh-my-pi/pi-ai";
16
- import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
17
16
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
18
- import { readSseJson } from "@oh-my-pi/pi-utils";
17
+ import { parseStreamingJson, readSseJson } from "@oh-my-pi/pi-utils";
19
18
 
20
19
  // Event stream adapter for proxy SSE events
21
20
  export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -157,11 +156,12 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
157
156
  }
158
157
 
159
158
  let sawTerminalEvent = false;
159
+ const partialJsonByIndex = new Map<number, string>();
160
160
  for await (const event of readSseJson<ProxyAssistantMessageEvent>(
161
161
  response.body as ReadableStream<Uint8Array>,
162
162
  options.signal,
163
163
  )) {
164
- const parsedEvent = processProxyEvent(model, event, partial);
164
+ const parsedEvent = processProxyEvent(model, event, partial, partialJsonByIndex);
165
165
  if (parsedEvent) {
166
166
  if (parsedEvent.type === "done" || parsedEvent.type === "error") {
167
167
  sawTerminalEvent = true;
@@ -183,6 +183,7 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
183
183
  const errorMessage = error instanceof Error ? error.message : String(error);
184
184
  const reason = options.signal?.aborted ? "aborted" : "error";
185
185
  partial.stopReason = reason;
186
+ scrubPartialJson(partial);
186
187
  partial.errorMessage = errorMessage;
187
188
  stream.push({
188
189
  type: "error",
@@ -200,13 +201,33 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
200
201
  return stream;
201
202
  }
202
203
 
204
+ /**
205
+ * Remove the `partialJson` streaming field from any tool-call content blocks
206
+ * that still carry it (e.g. when the stream ended without a `toolcall_end`).
207
+ */
208
+ function scrubPartialJson(partial: AssistantMessage): void {
209
+ for (const block of partial.content) {
210
+ if (block?.type === "toolCall") {
211
+ delete (block as ToolCall & { partialJson?: string }).partialJson;
212
+ }
213
+ }
214
+ }
215
+
203
216
  /**
204
217
  * Process a proxy event and update the partial message.
218
+ *
219
+ * Streaming `partialJson` for in-progress tool calls is accumulated in a
220
+ * side-channel map keyed by `contentIndex` and also written onto the content
221
+ * object (as a typed intersection field) so downstream renderers can read it
222
+ * during streaming. The field is deleted at `toolcall_end` and scrubbed from
223
+ * any remaining blocks at `done`/`error` to guarantee it never leaks into the
224
+ * final `AssistantMessage`.
205
225
  */
206
226
  function processProxyEvent(
207
227
  model: Model,
208
228
  proxyEvent: ProxyAssistantMessageEvent,
209
229
  partial: AssistantMessage,
230
+ partialJsonByIndex: Map<number, string>,
210
231
  ): AssistantMessageEvent | undefined {
211
232
  switch (proxyEvent.type) {
212
233
  case "start":
@@ -295,14 +316,16 @@ function processProxyEvent(
295
316
  name: proxyEvent.toolName,
296
317
  arguments: {},
297
318
  partialJson: "",
298
- } satisfies ToolCall & { partialJson: string } as ToolCall;
319
+ } as ToolCall & { partialJson: string };
320
+ partialJsonByIndex.set(proxyEvent.contentIndex, "");
299
321
  return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
300
-
301
322
  case "toolcall_delta": {
302
323
  const content = partial.content[proxyEvent.contentIndex];
303
324
  if (content?.type === "toolCall") {
304
- (content as any).partialJson += proxyEvent.delta;
305
- content.arguments = parseStreamingJson((content as any).partialJson) || {};
325
+ const acc = (partialJsonByIndex.get(proxyEvent.contentIndex) ?? "") + proxyEvent.delta;
326
+ partialJsonByIndex.set(proxyEvent.contentIndex, acc);
327
+ content.arguments = parseStreamingJson(acc) || {};
328
+ (content as ToolCall & { partialJson: string }).partialJson = acc;
306
329
  partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
307
330
  return {
308
331
  type: "toolcall_delta",
@@ -317,7 +340,8 @@ function processProxyEvent(
317
340
  case "toolcall_end": {
318
341
  const content = partial.content[proxyEvent.contentIndex];
319
342
  if (content?.type === "toolCall") {
320
- delete (content as any).partialJson;
343
+ partialJsonByIndex.delete(proxyEvent.contentIndex);
344
+ delete (content as ToolCall & { partialJson?: string }).partialJson;
321
345
  return {
322
346
  type: "toolcall_end",
323
347
  contentIndex: proxyEvent.contentIndex,
@@ -332,6 +356,7 @@ function processProxyEvent(
332
356
  partial.stopReason = proxyEvent.reason;
333
357
  partial.usage = proxyEvent.usage;
334
358
  calculateCost(model, partial.usage);
359
+ scrubPartialJson(partial);
335
360
  return { type: "done", reason: proxyEvent.reason, message: partial };
336
361
 
337
362
  case "error":
@@ -339,6 +364,7 @@ function processProxyEvent(
339
364
  partial.errorMessage = proxyEvent.errorMessage;
340
365
  partial.usage = proxyEvent.usage;
341
366
  calculateCost(model, partial.usage);
367
+ scrubPartialJson(partial);
342
368
  return { type: "error", reason: proxyEvent.reason, error: partial };
343
369
  }
344
370
  }
@@ -0,0 +1,13 @@
1
+ import type { AssistantMessage, Message } from "@oh-my-pi/pi-ai";
2
+
3
+ /** Detects API-level provider refusals that are terminal errors, not dialogue to replay. */
4
+ export function isProviderRefusalMessage(message: AssistantMessage): boolean {
5
+ if (message.stopReason !== "error") return false;
6
+ const stopType = message.stopDetails?.type;
7
+ return stopType === "refusal" || stopType === "sensitive";
8
+ }
9
+
10
+ /** Removes API-level provider refusals from live provider replay while preserving other messages. */
11
+ export function filterProviderReplayMessages(messages: readonly Message[]): Message[] {
12
+ return messages.filter(message => message.role !== "assistant" || !isProviderRefusalMessage(message));
13
+ }
package/src/types.ts CHANGED
@@ -37,6 +37,15 @@ export type StreamFn = (
37
37
  */
38
38
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
39
39
 
40
+ export interface AgentTurnEndContext {
41
+ /** Assistant/user message that just completed this turn boundary. */
42
+ message: AgentMessage;
43
+ /** Tool results produced by this turn, already paired with `message` in the live context. */
44
+ toolResults: ToolResultMessage[];
45
+ /** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
46
+ willContinue: boolean;
47
+ }
48
+
40
49
  /**
41
50
  * A soft tool requirement: the host wants `toolName` called before the loop
42
51
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -327,6 +336,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
327
336
  */
328
337
  getServiceTier?: (model: Model) => ServiceTier | undefined;
329
338
 
339
+ /**
340
+ * Per-call working-directory resolver, read once per LLM call. When set, its
341
+ * return value overrides the static {@link SimpleStreamOptions.cwd} for the
342
+ * request (falling back to that static `cwd` when it returns `undefined`).
343
+ * Lets the host reflect a session move (`/move`, which updates the working
344
+ * directory without reconstructing the loop config) into provider options —
345
+ * e.g. GitLab Duo Agent namespace/project discovery keys off this cwd's git
346
+ * remote, so a stale value would strand discovery on the original repo.
347
+ */
348
+ getCwd?: () => string | undefined;
349
+
330
350
  /**
331
351
  * Called after a tool call has been validated and is about to execute.
332
352
  *
@@ -346,11 +366,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
346
366
  ) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
347
367
  /**
348
368
  * Called after a turn ends and before the loop polls steering/asides for the
349
- * next iteration. Use this for awaited per-turn bookkeeping that must be
350
- * visible before the next model request (e.g. synchronizing an advisor's
351
- * backlog so advice produced during the wait is injected as an aside).
369
+ * next iteration. `context` carries the just-finished turn; `context.willContinue`
370
+ * is true when the current tool-loop batch is continuing without yielding to
371
+ * post-turn steering.
352
372
  */
353
- onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
373
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
354
374
 
355
375
  /**
356
376
  * Called once an assistant message is finalized from the model stream, before