@oh-my-pi/pi-agent-core 16.1.23 → 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 +13 -0
- package/dist/types/agent.d.ts +11 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/replay-policy.d.ts +5 -0
- package/dist/types/types.d.ts +10 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +4 -0
- package/src/agent.ts +24 -2
- package/src/compaction/messages.ts +1 -2
- package/src/compaction/openai.ts +69 -12
- package/src/index.ts +2 -0
- package/src/proxy.ts +34 -8
- package/src/replay-policy.ts +13 -0
- package/src/types.ts +11 -0
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
|
package/dist/types/agent.d.ts
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.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.
|
|
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
|
@@ -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
|
|
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 =>
|
|
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
|
|
216
|
+
return message;
|
|
218
217
|
case "toolResult":
|
|
219
218
|
return {
|
|
220
219
|
...message,
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
508
|
+
model: requestModel,
|
|
459
509
|
input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
|
|
460
510
|
instructions,
|
|
461
511
|
};
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
}
|
|
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
|
-
(
|
|
305
|
-
|
|
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
|
|
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
|
*
|