@oh-my-pi/pi-agent-core 16.1.23 → 16.2.1

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,19 @@
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
+
5
18
  ## [16.1.23] - 2026-06-26
6
19
 
7
20
  ### Changed
@@ -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.
@@ -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[];
@@ -284,6 +284,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
284
284
  * serving path while leaving the OpenAI/Anthropic tier untouched).
285
285
  */
286
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;
287
297
  /**
288
298
  * Called after a tool call has been validated and is about to execute.
289
299
  *
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.23",
4
+ "version": "16.2.1",
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.23",
39
- "@oh-my-pi/pi-catalog": "16.1.23",
40
- "@oh-my-pi/pi-natives": "16.1.23",
41
- "@oh-my-pi/pi-utils": "16.1.23",
42
- "@oh-my-pi/pi-wire": "16.1.23",
43
- "@oh-my-pi/snapcompact": "16.1.23",
38
+ "@oh-my-pi/pi-ai": "16.2.1",
39
+ "@oh-my-pi/pi-catalog": "16.2.1",
40
+ "@oh-my-pi/pi-natives": "16.2.1",
41
+ "@oh-my-pi/pi-utils": "16.2.1",
42
+ "@oh-my-pi/pi-wire": "16.2.1",
43
+ "@oh-my-pi/snapcompact": "16.2.1",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -1225,6 +1225,9 @@ async function streamAssistantResponse(
1225
1225
  const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1226
1226
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1227
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;
1228
1231
 
1229
1232
  const chatStepNumber = stepCounter.count;
1230
1233
  stepCounter.count += 1;
@@ -1276,6 +1279,7 @@ async function streamAssistantResponse(
1276
1279
  disableReasoning: effectiveDisableReasoning,
1277
1280
  temperature: effectiveTemperature,
1278
1281
  serviceTier: effectiveServiceTier,
1282
+ cwd: effectiveCwd,
1279
1283
  signal: finalRequestSignal,
1280
1284
  onResponse: captureOnResponse,
1281
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,
@@ -54,10 +55,13 @@ import { isSoftToolRequirement } from "./types";
54
55
  import { EventLoopKeepalive } from "./utils/yield";
55
56
 
56
57
  /**
57
- * Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
58
+ * Default convertToLlm: Keep only LLM-compatible replay messages.
58
59
  */
59
60
  function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
60
- 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
+ });
61
65
  }
62
66
 
63
67
  const ANTHROPIC_OUTPUT_BLOCKED_PREFIX = "Output blocked by conten";
@@ -269,6 +273,17 @@ export interface AgentOptions {
269
273
  */
270
274
  cursorOnToolResult?: CursorToolResultHandler;
271
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;
272
287
  /**
273
288
  * Called after a tool call has been validated and is about to execute.
274
289
  * See {@link AgentLoopConfig.beforeToolCall} for full semantics.
@@ -355,6 +370,9 @@ export class Agent {
355
370
  #getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
356
371
  #cursorExecHandlers?: CursorExecHandlers;
357
372
  #cursorOnToolResult?: CursorToolResultHandler;
373
+ #cwd?: string;
374
+ #cwdResolver?: () => string | undefined;
375
+
358
376
  #runningPrompt?: Promise<void>;
359
377
  #resolveRunningPrompt?: () => void;
360
378
  #kimiApiFormat?: "openai" | "anthropic";
@@ -430,6 +448,8 @@ export class Agent {
430
448
  this.#getToolContext = opts.getToolContext;
431
449
  this.#cursorExecHandlers = opts.cursorExecHandlers;
432
450
  this.#cursorOnToolResult = opts.cursorOnToolResult;
451
+ this.#cwd = opts.cwd;
452
+ this.#cwdResolver = opts.cwdResolver;
433
453
  this.#kimiApiFormat = opts.kimiApiFormat;
434
454
  this.#preferWebsockets = opts.preferWebsockets;
435
455
  this.#transformToolCallArguments = opts.transformToolCallArguments;
@@ -1134,6 +1154,8 @@ export class Agent {
1134
1154
  },
1135
1155
  cursorExecHandlers: this.#cursorExecHandlers,
1136
1156
  cursorOnToolResult,
1157
+ cwd: this.#cwd,
1158
+ getCwd: this.#cwdResolver,
1137
1159
  transformToolCallArguments: this.#transformToolCallArguments,
1138
1160
  intentTracing: this.#intentTracing,
1139
1161
  pruneToolDescriptions: this.#pruneToolDescriptions,
@@ -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") {
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
@@ -336,6 +336,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
336
336
  */
337
337
  getServiceTier?: (model: Model) => ServiceTier | undefined;
338
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
+
339
350
  /**
340
351
  * Called after a tool call has been validated and is about to execute.
341
352
  *