@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 +23 -0
- package/dist/types/agent.d.ts +13 -2
- package/dist/types/compaction/compaction.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/replay-policy.d.ts +5 -0
- package/dist/types/types.d.ts +22 -4
- package/package.json +7 -7
- package/src/agent-loop.ts +11 -3
- package/src/agent.ts +32 -5
- package/src/compaction/compaction.ts +56 -7
- package/src/compaction/messages.ts +1 -2
- package/src/compaction/openai.ts +69 -12
- package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
- package/src/index.ts +2 -0
- package/src/proxy.ts +34 -8
- package/src/replay-policy.ts +13 -0
- package/src/types.ts +24 -4
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
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -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;
|
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
|
@@ -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.
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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.
|
|
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
|
@@ -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
|
|
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 =>
|
|
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(
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
1219
|
+
previousSummaryForCompaction,
|
|
1179
1220
|
summaryOptions,
|
|
1180
1221
|
);
|
|
1181
|
-
} else if (
|
|
1222
|
+
} else if (previousSummaryForCompaction) {
|
|
1182
1223
|
// No new messages to summarize, preserve previous summary
|
|
1183
|
-
summary =
|
|
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
|
|
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
|
@@ -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.
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|