@oh-my-pi/pi-agent-core 18.4.2 → 18.4.4
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 +25 -0
- package/README.md +4 -0
- package/dist/types/agent.d.ts +26 -0
- package/dist/types/compaction/azure-openai-endpoint.d.ts +5 -0
- package/dist/types/compaction/bedrock.d.ts +18 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +3 -1
- package/dist/types/compaction/openai.d.ts +1 -2
- package/dist/types/compaction/tool-protection.d.ts +2 -0
- package/dist/types/speculative-execution.d.ts +2 -1
- package/dist/types/tool-context.d.ts +3 -2
- package/dist/types/types.d.ts +25 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +6 -8
- package/src/agent.ts +94 -24
- package/src/compaction/azure-openai-endpoint.ts +25 -0
- package/src/compaction/bedrock.ts +48 -0
- package/src/compaction/compaction-v2-streaming.ts +25 -34
- package/src/compaction/openai.ts +22 -40
- package/src/compaction/pruning.ts +1 -7
- package/src/compaction/shake.ts +1 -7
- package/src/compaction/tool-protection.ts +9 -1
- package/src/speculative-execution.ts +12 -0
- package/src/telemetry.ts +4 -3
- package/src/tool-context.ts +9 -3
- package/src/types.ts +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.4] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `Agent.replaceQueue()` to replace one pending queue without changing the other queue ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
|
|
10
|
+
- Added queued-message grouping so owned companion records and their user prompt are dequeued together in `one-at-a-time` mode ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
|
|
11
|
+
- Added `Agent.onQueueChange()`, a listener called whenever a steering/follow-up queue mutator (enqueue, dequeue on delivery, clear, or restore) runs, so hosts can observe queue changes without polling ([#11872](https://github.com/can1357/oh-my-pi/pull/11872) by [@andrebrait](https://github.com/andrebrait)).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed GPT models on Amazon Bedrock's OpenAI routes (bedrock-runtime and bedrock-mantle `/openai/...`) falling back to a local summary instead of OpenAI's native remote compaction; set `remoteCompaction.enabled: false` to opt out ([#13311](https://github.com/can1357/oh-my-pi/pull/13311) by [@mustafaabidali](https://github.com/mustafaabidali)).
|
|
16
|
+
- Fixed native compaction on Amazon Bedrock's OpenAI routes skipping the provider's request setup, which sent Bedrock Mantle compaction to an unresolved `{region}` host and skipped configured headers and proxies; other providers' compaction requests are unchanged ([#13311](https://github.com/can1357/oh-my-pi/pull/13311) by [@mustafaabidali](https://github.com/mustafaabidali)).
|
|
17
|
+
|
|
18
|
+
## [18.4.3] - 2026-09-28
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added `transformAssistantMessagePreservesToolCalls`, letting stream speculation and direct speculative candidates run under a `transformAssistantMessage` that never rewrites streamed tool calls
|
|
23
|
+
- Added `authorizeLaunch` to the speculative execution host and coordinator so tool stream sessions can start host-approved effectful work (e.g. subagents) before their call dispatches
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed auto-compaction with the `remote` method failing on long Codex/OpenAI sessions with "Remote compaction input exceeds the context window" ([#13611](https://github.com/can1357/oh-my-pi/issues/13611))
|
|
28
|
+
- Fixed passive tool-call context being repeated when several calls in one batch returned the same text; identical per-call context is now delivered once, at its first position ([#13633](https://github.com/can1357/oh-my-pi/pull/13633) by [@andrebrait](https://github.com/andrebrait))
|
|
29
|
+
|
|
5
30
|
## [18.4.2] - 2026-09-28
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -251,6 +251,10 @@ agent.followUp({
|
|
|
251
251
|
Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer
|
|
252
252
|
steering until the current turn completes.
|
|
253
253
|
|
|
254
|
+
Hosts can use `setQueuedMessageGrouping((previous, next) => boolean)` to keep adjacent companion
|
|
255
|
+
records and their prompt together in `one-at-a-time` mode. Without a grouping predicate, records
|
|
256
|
+
remain separate. `replaceQueue("steering" | "followUp", messages)` replaces only the selected queue.
|
|
257
|
+
|
|
254
258
|
## Custom Message Types
|
|
255
259
|
|
|
256
260
|
Extend `AgentMessage` via declaration merging:
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -221,6 +221,8 @@ export interface AgentOptions {
|
|
|
221
221
|
* tool-call arguments). See {@link AgentLoopConfig.transformAssistantMessage}.
|
|
222
222
|
*/
|
|
223
223
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
224
|
+
/** See {@link AgentLoopConfig.transformAssistantMessagePreservesToolCalls}. */
|
|
225
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
224
226
|
/**
|
|
225
227
|
* Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
|
|
226
228
|
* GenAI-semantic-convention spans using the global tracer provider. See
|
|
@@ -257,6 +259,8 @@ export declare class Agent {
|
|
|
257
259
|
* UI emission, and tool dispatch. Reassign at any time to swap the implementation.
|
|
258
260
|
*/
|
|
259
261
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
262
|
+
/** Declares {@link transformAssistantMessage} never rewrites streamed tool calls; reassign alongside it. */
|
|
263
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
260
264
|
/**
|
|
261
265
|
* Hook that peeks whether interrupting IRC asides are queued for the next boundary.
|
|
262
266
|
*/
|
|
@@ -407,6 +411,12 @@ export declare class Agent {
|
|
|
407
411
|
*/
|
|
408
412
|
buildSideRequestContext(llmMessages: Message[], systemPrompt?: string[]): Promise<Context>;
|
|
409
413
|
subscribe(fn: (e: AgentEvent) => void): () => void;
|
|
414
|
+
/** Register a listener notified after any steering/follow-up queue mutator
|
|
415
|
+
* (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
|
|
416
|
+
* the queue itself has no concept of display filtering — so listeners
|
|
417
|
+
* recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
|
|
418
|
+
* (or a higher-level view) on notification. */
|
|
419
|
+
onQueueChange(listener: () => void): () => void;
|
|
410
420
|
/** Register an independently removable hook that runs before queued messages are consumed. */
|
|
411
421
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
412
422
|
/** Register an independently removable hook that runs immediately before each model call. */
|
|
@@ -457,6 +467,16 @@ export declare class Agent {
|
|
|
457
467
|
setTools(t: AgentTool<any>[]): void;
|
|
458
468
|
replaceMessages(ms: AgentMessage[]): void;
|
|
459
469
|
replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]): void;
|
|
470
|
+
/**
|
|
471
|
+
* Replace one pending queue without changing the other queue.
|
|
472
|
+
*
|
|
473
|
+
* The caller's snapshot comes from {@link peekSteeringQueue} /
|
|
474
|
+
* {@link peekFollowUpQueue}, which prepend the live claimed batch. Installing
|
|
475
|
+
* it while that claim is still live would commit a removed message anyway and
|
|
476
|
+
* deliver the surviving claimed prefix twice, so the claim and its pending
|
|
477
|
+
* delivery are dropped here exactly as {@link replaceQueues} does.
|
|
478
|
+
*/
|
|
479
|
+
replaceQueue(queue: "steering" | "followUp", messages: readonly AgentMessage[]): void;
|
|
460
480
|
appendMessage(m: AgentMessage): void;
|
|
461
481
|
popMessage(): AgentMessage | undefined;
|
|
462
482
|
/**
|
|
@@ -495,6 +515,12 @@ export declare class Agent {
|
|
|
495
515
|
/** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
|
|
496
516
|
getPendingToolResults(): readonly ToolResultMessage[];
|
|
497
517
|
get isAborting(): boolean;
|
|
518
|
+
/**
|
|
519
|
+
* Join adjacent queued records into one delivery unit in one-at-a-time mode.
|
|
520
|
+
* The predicate must be synchronous and side-effect-free. By default each
|
|
521
|
+
* record is independent; all mode, queue inspection, and removal stay unchanged.
|
|
522
|
+
*/
|
|
523
|
+
setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void;
|
|
498
524
|
/**
|
|
499
525
|
* Remove and return the last steering message from the queue (LIFO).
|
|
500
526
|
* Used by dequeue keybinding.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
2
|
+
/** Azure OpenAI base URL for compaction requests: env override, resource name, then the model's baseUrl. */
|
|
3
|
+
export declare function resolveAzureOpenAiBaseUrl(model: Model): string;
|
|
4
|
+
/** Append `api-version` unless the endpoint already carries one. */
|
|
5
|
+
export declare function appendAzureApiVersion(endpoint: string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
|
|
2
|
+
export interface BedrockCompactionRequest {
|
|
3
|
+
model: Model;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
fetch: FetchImpl;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Shape a native compaction request for Amazon Bedrock's OpenAI routes
|
|
9
|
+
* (`isBedrockOpenAIUrl`) the way stream dispatch shapes a normal Bedrock turn:
|
|
10
|
+
* configured headers, transport fetch (proxy, CA, User-Agent, request
|
|
11
|
+
* recording), then the provider's model and request hooks. The bundled
|
|
12
|
+
* `bedrock-mantle` provider's hook fills in `{region}` and picks bearer or
|
|
13
|
+
* SigV4 auth; without it compaction targets an unresolved host.
|
|
14
|
+
*
|
|
15
|
+
* Only Bedrock requests come through here; other providers' compaction
|
|
16
|
+
* requests keep their own transport.
|
|
17
|
+
*/
|
|
18
|
+
export declare function prepareBedrockCompactionRequest(model: Model, apiKey: string, fetch: FetchImpl | undefined, signal: AbortSignal | undefined): Promise<BedrockCompactionRequest>;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* compaction output item, then install retained real user messages plus that
|
|
7
7
|
* compaction item as replacement history.
|
|
8
8
|
*/
|
|
9
|
-
import type { CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
|
|
9
|
+
import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { type OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
11
11
|
/** Retained-message budget Codex uses after streamed V2 compaction. */
|
|
12
12
|
export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
|
|
@@ -42,6 +42,8 @@ export interface CompactionV2Response {
|
|
|
42
42
|
export declare function getCompactionV2Endpoint(model: Model): string | undefined;
|
|
43
43
|
/** Check whether a model can use streaming V2 compaction. */
|
|
44
44
|
export declare function shouldUseCompactionV2Streaming(model: Model): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">;
|
|
45
|
+
/** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
|
|
46
|
+
export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
|
|
45
47
|
/** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
|
|
46
48
|
export declare function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number;
|
|
47
49
|
/** Build a V2 streaming compaction request from Responses-native history. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* summarization endpoints that accept `{ systemPrompt, prompt }` and reply
|
|
15
15
|
* with `{ summary, shortSummary? }`.
|
|
16
16
|
*/
|
|
17
|
-
import type {
|
|
17
|
+
import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
|
|
18
18
|
import { Tokenizer } from "../tokenizer.js";
|
|
19
19
|
export * from "./compaction-v2-streaming.js";
|
|
20
20
|
export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
@@ -83,7 +83,6 @@ export interface RemoteCompactionResponse {
|
|
|
83
83
|
summary: string;
|
|
84
84
|
shortSummary?: string;
|
|
85
85
|
}
|
|
86
|
-
export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
|
|
87
86
|
export declare function shouldUseOpenAiRemoteCompaction(model: Model): boolean;
|
|
88
87
|
export declare function getPreservedOpenAiRemoteCompactionData(preserveData: Record<string, unknown> | undefined): OpenAiRemoteCompactionPreserveData | undefined;
|
|
89
88
|
export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Record<string, unknown> | undefined, remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined): Record<string, unknown> | undefined;
|
|
@@ -7,6 +7,8 @@ export interface ProtectedToolContext {
|
|
|
7
7
|
}
|
|
8
8
|
export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) => boolean);
|
|
9
9
|
export declare function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall>;
|
|
10
|
+
/** Return the tool-result message carried by a session entry, if any. */
|
|
11
|
+
export declare function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined;
|
|
10
12
|
/**
|
|
11
13
|
* Extract the `path` argument from a paired `read` tool call, when the result
|
|
12
14
|
* is a `read` result carrying a string path. Returns `undefined` otherwise.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import type { AgentContext, AgentLoopConfig, AgentTool, AgentToolCall, AgentToolResult, SpeculativeChildDefinition, SpeculativeChildHandle, SpeculativeToolExecutionConfig, ToolSpeculationEffect, ToolSpeculationStreamSession } from "./types.js";
|
|
2
|
+
import type { AgentContext, AgentLoopConfig, AgentTool, AgentToolCall, AgentToolResult, SpeculativeAuthorization, SpeculativeChildDefinition, SpeculativeChildHandle, SpeculativeLaunchContext, SpeculativeToolExecutionConfig, ToolSpeculationEffect, ToolSpeculationStreamSession } from "./types.js";
|
|
3
3
|
export type SpeculativeRawOutcome = {
|
|
4
4
|
result: AgentToolResult<unknown>;
|
|
5
5
|
isError: boolean;
|
|
@@ -55,6 +55,7 @@ export declare class SpeculativeOperationCoordinator {
|
|
|
55
55
|
*/
|
|
56
56
|
directExecutionArgsFor(toolCallId: string, rawArgs: Readonly<Record<string, unknown>>): Record<string, unknown> | undefined;
|
|
57
57
|
reconcileFinalCalls(calls: ReadonlyMap<string, AgentToolCall>): Promise<void>;
|
|
58
|
+
authorizeLaunch(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization>;
|
|
58
59
|
discardChildren(parentToolCallId: string, reason: string): Promise<void>;
|
|
59
60
|
claim(tool: AgentTool | undefined, toolCall: AgentToolCall, args: Record<string, unknown>): Promise<SpeculativeRawOutcome | undefined>;
|
|
60
61
|
admitFinalized(context: AgentContext, toolCall: AgentToolCall, loopConfig: AgentLoopConfig, signal: AbortSignal | undefined): void;
|
|
@@ -20,8 +20,9 @@ export type ToolResultWithAdditionalContext = ToolResultMessage & {
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function isNonBlankContext(value: unknown): value is string;
|
|
22
22
|
/**
|
|
23
|
-
* Join passive context values in order, dropping blanks
|
|
24
|
-
*
|
|
23
|
+
* Join passive context values in order, dropping blanks and repeats of an
|
|
24
|
+
* earlier value (compared without surrounding whitespace; the first original
|
|
25
|
+
* is kept). Returns undefined when nothing remains.
|
|
25
26
|
*/
|
|
26
27
|
export declare function joinAdditionalContext(values: Iterable<string | undefined>): string | undefined;
|
|
27
28
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -515,6 +515,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
515
515
|
* the turn).
|
|
516
516
|
*/
|
|
517
517
|
transformAssistantMessage?: (message: AssistantMessage, signal?: AbortSignal) => Promise<void> | void;
|
|
518
|
+
/**
|
|
519
|
+
* Declares that {@link transformAssistantMessage} never rewrites or removes a
|
|
520
|
+
* tool call the model streamed (it may edit text or append new calls). Stream
|
|
521
|
+
* speculation sessions and direct speculative candidates plan from streamed
|
|
522
|
+
* calls, so they stay disabled under a transform unless this is set.
|
|
523
|
+
*/
|
|
524
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
518
525
|
/**
|
|
519
526
|
* Called after a tool finishes executing, before `tool_execution_end` and the
|
|
520
527
|
* tool-result message are emitted.
|
|
@@ -659,8 +666,22 @@ export interface SpeculativeExecutionHost {
|
|
|
659
666
|
validate?(context: SpeculativeCommitContext): boolean | Promise<boolean>;
|
|
660
667
|
commit?(context: SpeculativeCommitContext, commitDefault: () => Promise<AgentToolResult<unknown>>): Promise<SpeculativeCommitDecision>;
|
|
661
668
|
discard?(context: SpeculativeDiscardContext): void | Promise<void>;
|
|
669
|
+
/**
|
|
670
|
+
* Authorize a stream session to start effectful work (e.g. subagents) from
|
|
671
|
+
* partially streamed arguments. The session owns that work and must abort it
|
|
672
|
+
* when the finalized call is invalid, blocked, or changed. Hosts without this
|
|
673
|
+
* hook deny every launch.
|
|
674
|
+
*/
|
|
675
|
+
authorizeLaunch?(context: SpeculativeLaunchContext): SpeculativeAuthorization | Promise<SpeculativeAuthorization>;
|
|
662
676
|
close?(reason: string): void | Promise<void>;
|
|
663
677
|
}
|
|
678
|
+
/** Effectful work a tool-owned stream session asks to start before its outer call dispatches. */
|
|
679
|
+
export interface SpeculativeLaunchContext {
|
|
680
|
+
tool: SpeculativeToolReference;
|
|
681
|
+
toolCall: AgentToolCall;
|
|
682
|
+
/** Arguments the launch was planned from: the streamed prefix of the outer call. */
|
|
683
|
+
args: Readonly<Record<string, unknown>>;
|
|
684
|
+
}
|
|
664
685
|
export interface ToolSpeculationStreamContext {
|
|
665
686
|
readonly coordinator: SpeculativeOperationSink;
|
|
666
687
|
readonly parentToolCallId: string;
|
|
@@ -703,6 +724,8 @@ export interface ToolSpeculationStreamSession {
|
|
|
703
724
|
export interface SpeculativeOperationSink {
|
|
704
725
|
readonly maxInFlight: number;
|
|
705
726
|
admit(definition: SpeculativeChildDefinition): Promise<SpeculativeChildHandle | undefined>;
|
|
727
|
+
/** Host-gated permission for effectful stream work; see {@link SpeculativeExecutionHost.authorizeLaunch}. */
|
|
728
|
+
authorizeLaunch?(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization>;
|
|
706
729
|
discardChildren?(parentToolCallId: string, reason: string): void | Promise<void>;
|
|
707
730
|
close(reason: string): void | Promise<void>;
|
|
708
731
|
}
|
|
@@ -755,7 +778,8 @@ export interface SpeculativeToolExecutionConfig {
|
|
|
755
778
|
* ignored when `block` is true.
|
|
756
779
|
*
|
|
757
780
|
* Set `additionalContext` to attach passive model-visible context to this call.
|
|
758
|
-
* Non-empty values from a tool batch are injected in assistant tool-call order
|
|
781
|
+
* Non-empty values from a tool batch are injected in assistant tool-call order,
|
|
782
|
+
* a value identical to an earlier one in the batch only once,
|
|
759
783
|
* after every result settles and before the next provider request. It is
|
|
760
784
|
* dropped when the call is blocked or skipped, or when its final result is an
|
|
761
785
|
* error (including an approval denial raised by the tool's own gate). Within a
|
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": "18.4.
|
|
4
|
+
"version": "18.4.4",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@oh-my-pi/pi-ai": "18.4.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.4.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.4.4",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.4.4",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.4.4",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.4.4",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.4.4",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.4.4",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.4.
|
|
50
|
+
"@oh-my-pi/omptype": "18.4.4",
|
|
51
51
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
52
52
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
53
53
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -2098,6 +2098,8 @@ async function streamAssistantResponse(
|
|
|
2098
2098
|
signal: requestSignal,
|
|
2099
2099
|
})
|
|
2100
2100
|
: undefined;
|
|
2101
|
+
const speculationPlansFromStream =
|
|
2102
|
+
!config.transformAssistantMessage || config.transformAssistantMessagePreservesToolCalls === true;
|
|
2101
2103
|
|
|
2102
2104
|
let providerStreamSettled = false;
|
|
2103
2105
|
let speculationSettled = false;
|
|
@@ -2327,15 +2329,11 @@ async function streamAssistantResponse(
|
|
|
2327
2329
|
case "toolcall_delta":
|
|
2328
2330
|
case "toolcall_end":
|
|
2329
2331
|
if (partialMessage) {
|
|
2330
|
-
if (
|
|
2331
|
-
event.type === "toolcall_start" &&
|
|
2332
|
-
speculationCoordinator &&
|
|
2333
|
-
!config.transformAssistantMessage
|
|
2334
|
-
) {
|
|
2332
|
+
if (event.type === "toolcall_start" && speculationCoordinator && speculationPlansFromStream) {
|
|
2335
2333
|
// Stream sessions plan from pre-transform arguments, exactly like
|
|
2336
2334
|
// direct candidates (see admitFinalized below): with a transformer
|
|
2337
|
-
//
|
|
2338
|
-
// work started from the original would be phantom I/O.
|
|
2335
|
+
// that may rewrite calls, the authoritative call may differ, so any
|
|
2336
|
+
// speculative work started from the original would be phantom I/O.
|
|
2339
2337
|
speculationCoordinator.register(event.contentIndex);
|
|
2340
2338
|
const toolCall = event.partial.content[event.contentIndex];
|
|
2341
2339
|
if (toolCall?.type === "toolCall") {
|
|
@@ -2432,7 +2430,7 @@ async function streamAssistantResponse(
|
|
|
2432
2430
|
event.type === "toolcall_end" &&
|
|
2433
2431
|
speculationCoordinator &&
|
|
2434
2432
|
speculationConfig &&
|
|
2435
|
-
|
|
2433
|
+
speculationPlansFromStream
|
|
2436
2434
|
) {
|
|
2437
2435
|
speculationCoordinator.admitFinalized(context, event.toolCall, config, requestSignal);
|
|
2438
2436
|
}
|
package/src/agent.ts
CHANGED
|
@@ -346,6 +346,8 @@ export interface AgentOptions {
|
|
|
346
346
|
* tool-call arguments). See {@link AgentLoopConfig.transformAssistantMessage}.
|
|
347
347
|
*/
|
|
348
348
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
349
|
+
/** See {@link AgentLoopConfig.transformAssistantMessagePreservesToolCalls}. */
|
|
350
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
349
351
|
|
|
350
352
|
/**
|
|
351
353
|
* Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
|
|
@@ -404,6 +406,7 @@ export class Agent {
|
|
|
404
406
|
};
|
|
405
407
|
#tokenizer = new Tokenizer(this.#state.model);
|
|
406
408
|
#listeners = new Set<(e: AgentEvent) => void>();
|
|
409
|
+
#queueListeners = new Set<() => void>();
|
|
407
410
|
#abortController?: AbortController;
|
|
408
411
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
409
412
|
#transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
|
@@ -428,6 +431,7 @@ export class Agent {
|
|
|
428
431
|
next: number;
|
|
429
432
|
}>();
|
|
430
433
|
#steeringWaiters = new Set<() => void>();
|
|
434
|
+
#queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
|
|
431
435
|
|
|
432
436
|
#steeringMode: "all" | "one-at-a-time";
|
|
433
437
|
#followUpMode: "all" | "one-at-a-time";
|
|
@@ -511,6 +515,8 @@ export class Agent {
|
|
|
511
515
|
* UI emission, and tool dispatch. Reassign at any time to swap the implementation.
|
|
512
516
|
*/
|
|
513
517
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
518
|
+
/** Declares {@link transformAssistantMessage} never rewrites streamed tool calls; reassign alongside it. */
|
|
519
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
514
520
|
/**
|
|
515
521
|
* Hook that peeks whether interrupting IRC asides are queued for the next boundary.
|
|
516
522
|
*/
|
|
@@ -576,6 +582,7 @@ export class Agent {
|
|
|
576
582
|
this.beforeToolCall = opts.beforeToolCall;
|
|
577
583
|
this.afterToolCall = opts.afterToolCall;
|
|
578
584
|
this.transformAssistantMessage = opts.transformAssistantMessage;
|
|
585
|
+
this.transformAssistantMessagePreservesToolCalls = opts.transformAssistantMessagePreservesToolCalls;
|
|
579
586
|
this.#telemetry = opts.telemetry;
|
|
580
587
|
this.#appendOnlyContext = opts.appendOnlyContext;
|
|
581
588
|
this.#transformProviderContext = opts.transformProviderContext;
|
|
@@ -909,6 +916,16 @@ export class Agent {
|
|
|
909
916
|
return () => this.#listeners.delete(fn);
|
|
910
917
|
}
|
|
911
918
|
|
|
919
|
+
/** Register a listener notified after any steering/follow-up queue mutator
|
|
920
|
+
* (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
|
|
921
|
+
* the queue itself has no concept of display filtering — so listeners
|
|
922
|
+
* recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
|
|
923
|
+
* (or a higher-level view) on notification. */
|
|
924
|
+
onQueueChange(listener: () => void): () => void {
|
|
925
|
+
this.#queueListeners.add(listener);
|
|
926
|
+
return () => this.#queueListeners.delete(listener);
|
|
927
|
+
}
|
|
928
|
+
|
|
912
929
|
/** Register an independently removable hook that runs before queued messages are consumed. */
|
|
913
930
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
|
|
914
931
|
const registration = (signal?: AbortSignal) => hook(signal);
|
|
@@ -996,6 +1013,7 @@ export class Agent {
|
|
|
996
1013
|
} else {
|
|
997
1014
|
this.#followUpQueue = [...claim.messages, ...this.#followUpQueue];
|
|
998
1015
|
}
|
|
1016
|
+
this.#emitQueueChanged();
|
|
999
1017
|
}
|
|
1000
1018
|
claim.controller.abort();
|
|
1001
1019
|
}
|
|
@@ -1021,6 +1039,7 @@ export class Agent {
|
|
|
1021
1039
|
this.#notifySteeringWaiters();
|
|
1022
1040
|
}
|
|
1023
1041
|
if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
|
|
1042
|
+
if (restored.steering.length > 0 || restored.followUp.length > 0) this.#emitQueueChanged();
|
|
1024
1043
|
}
|
|
1025
1044
|
|
|
1026
1045
|
/** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
|
|
@@ -1186,12 +1205,51 @@ export class Agent {
|
|
|
1186
1205
|
this.#state.messages = ms.slice();
|
|
1187
1206
|
}
|
|
1188
1207
|
|
|
1208
|
+
/** Signal that the steering/follow-up queue contents may have changed. Every
|
|
1209
|
+
* queue mutator below calls this once after mutating, so external layers
|
|
1210
|
+
* (AgentSession's `queue_update` coalescing) have a single seam to observe
|
|
1211
|
+
* enqueue, dequeue-on-delivery, clear, and restore without emits scattered
|
|
1212
|
+
* across every call site that queues or dequeues a message. */
|
|
1213
|
+
#emitQueueChanged(): void {
|
|
1214
|
+
for (const listener of this.#queueListeners) {
|
|
1215
|
+
try {
|
|
1216
|
+
listener();
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
logger.warn("Agent queue listener threw", {
|
|
1219
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1189
1225
|
replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
|
|
1190
1226
|
this.#steeringQueue = steering.slice();
|
|
1191
1227
|
this.#followUpQueue = followUp.slice();
|
|
1192
1228
|
this.#cancelQueuedMessagePreparation("steering");
|
|
1193
1229
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1194
1230
|
this.#notifySteeringWaiters();
|
|
1231
|
+
this.#emitQueueChanged();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Replace one pending queue without changing the other queue.
|
|
1236
|
+
*
|
|
1237
|
+
* The caller's snapshot comes from {@link peekSteeringQueue} /
|
|
1238
|
+
* {@link peekFollowUpQueue}, which prepend the live claimed batch. Installing
|
|
1239
|
+
* it while that claim is still live would commit a removed message anyway and
|
|
1240
|
+
* deliver the surviving claimed prefix twice, so the claim and its pending
|
|
1241
|
+
* delivery are dropped here exactly as {@link replaceQueues} does.
|
|
1242
|
+
*/
|
|
1243
|
+
replaceQueue(queue: "steering" | "followUp", messages: readonly AgentMessage[]): void {
|
|
1244
|
+
if (queue === "steering") {
|
|
1245
|
+
this.#steeringQueue = messages.slice();
|
|
1246
|
+
this.#cancelQueuedMessagePreparation("steering");
|
|
1247
|
+
this.#notifySteeringWaiters();
|
|
1248
|
+
} else {
|
|
1249
|
+
this.#followUpQueue = messages.slice();
|
|
1250
|
+
this.#cancelQueuedMessagePreparation("followUp");
|
|
1251
|
+
}
|
|
1252
|
+
this.#emitQueueChanged();
|
|
1195
1253
|
}
|
|
1196
1254
|
|
|
1197
1255
|
appendMessage(m: AgentMessage) {
|
|
@@ -1223,6 +1281,7 @@ export class Agent {
|
|
|
1223
1281
|
steer(m: AgentMessage) {
|
|
1224
1282
|
this.#steeringQueue.push(m);
|
|
1225
1283
|
this.#notifySteeringWaiters();
|
|
1284
|
+
this.#emitQueueChanged();
|
|
1226
1285
|
}
|
|
1227
1286
|
|
|
1228
1287
|
/**
|
|
@@ -1231,17 +1290,20 @@ export class Agent {
|
|
|
1231
1290
|
*/
|
|
1232
1291
|
followUp(m: AgentMessage) {
|
|
1233
1292
|
this.#followUpQueue.push(m);
|
|
1293
|
+
this.#emitQueueChanged();
|
|
1234
1294
|
}
|
|
1235
1295
|
|
|
1236
1296
|
clearSteeringQueue() {
|
|
1237
1297
|
this.#steeringQueue = [];
|
|
1238
1298
|
this.#cancelQueuedMessagePreparation("steering");
|
|
1239
1299
|
this.#notifySteeringWaiters();
|
|
1300
|
+
this.#emitQueueChanged();
|
|
1240
1301
|
}
|
|
1241
1302
|
|
|
1242
1303
|
clearFollowUpQueue() {
|
|
1243
1304
|
this.#followUpQueue = [];
|
|
1244
1305
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1306
|
+
this.#emitQueueChanged();
|
|
1245
1307
|
}
|
|
1246
1308
|
|
|
1247
1309
|
/**
|
|
@@ -1261,6 +1323,7 @@ export class Agent {
|
|
|
1261
1323
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1262
1324
|
this.#notifySteeringWaiters();
|
|
1263
1325
|
this.clearDeferredToolDirectives();
|
|
1326
|
+
this.#emitQueueChanged();
|
|
1264
1327
|
}
|
|
1265
1328
|
|
|
1266
1329
|
hasQueuedMessages(): boolean {
|
|
@@ -1312,32 +1375,34 @@ export class Agent {
|
|
|
1312
1375
|
return this.#abortController?.signal.aborted === true && this.#state.isStreaming;
|
|
1313
1376
|
}
|
|
1314
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* Join adjacent queued records into one delivery unit in one-at-a-time mode.
|
|
1380
|
+
* The predicate must be synchronous and side-effect-free. By default each
|
|
1381
|
+
* record is independent; all mode, queue inspection, and removal stay unchanged.
|
|
1382
|
+
*/
|
|
1383
|
+
setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void {
|
|
1384
|
+
this.#queuedMessageGrouping = predicate;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
#dequeueMessages(queue: readonly AgentMessage[], mode: "all" | "one-at-a-time"): AgentMessage[] {
|
|
1388
|
+
if (mode === "all") return queue.slice();
|
|
1389
|
+
let count = Math.min(1, queue.length);
|
|
1390
|
+
while (count < queue.length && this.#queuedMessageGrouping?.(queue[count - 1], queue[count])) count++;
|
|
1391
|
+
return queue.slice(0, count);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1315
1394
|
#dequeueSteeringMessages(): AgentMessage[] {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
return [first];
|
|
1321
|
-
}
|
|
1322
|
-
return [];
|
|
1323
|
-
}
|
|
1324
|
-
const steering = this.#steeringQueue.slice();
|
|
1325
|
-
this.#steeringQueue = [];
|
|
1326
|
-
return steering;
|
|
1395
|
+
const messages = this.#dequeueMessages(this.#steeringQueue, this.#steeringMode);
|
|
1396
|
+
this.#steeringQueue = this.#steeringQueue.slice(messages.length);
|
|
1397
|
+
if (messages.length > 0) this.#emitQueueChanged();
|
|
1398
|
+
return messages;
|
|
1327
1399
|
}
|
|
1328
1400
|
|
|
1329
1401
|
#dequeueFollowUpMessages(): AgentMessage[] {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
return [first];
|
|
1335
|
-
}
|
|
1336
|
-
return [];
|
|
1337
|
-
}
|
|
1338
|
-
const followUp = this.#followUpQueue.slice();
|
|
1339
|
-
this.#followUpQueue = [];
|
|
1340
|
-
return followUp;
|
|
1402
|
+
const messages = this.#dequeueMessages(this.#followUpQueue, this.#followUpMode);
|
|
1403
|
+
this.#followUpQueue = this.#followUpQueue.slice(messages.length);
|
|
1404
|
+
if (messages.length > 0) this.#emitQueueChanged();
|
|
1405
|
+
return messages;
|
|
1341
1406
|
}
|
|
1342
1407
|
|
|
1343
1408
|
/**
|
|
@@ -1346,7 +1411,9 @@ export class Agent {
|
|
|
1346
1411
|
*/
|
|
1347
1412
|
popLastSteer(): AgentMessage | undefined {
|
|
1348
1413
|
if (this.#steeringQueue.length === 0) this.#cancelQueuedMessagePreparation("steering", true);
|
|
1349
|
-
|
|
1414
|
+
const popped = this.#steeringQueue.pop();
|
|
1415
|
+
if (popped !== undefined) this.#emitQueueChanged();
|
|
1416
|
+
return popped;
|
|
1350
1417
|
}
|
|
1351
1418
|
|
|
1352
1419
|
/**
|
|
@@ -1355,7 +1422,9 @@ export class Agent {
|
|
|
1355
1422
|
*/
|
|
1356
1423
|
popLastFollowUp(): AgentMessage | undefined {
|
|
1357
1424
|
if (this.#followUpQueue.length === 0) this.#cancelQueuedMessagePreparation("followUp", true);
|
|
1358
|
-
|
|
1425
|
+
const popped = this.#followUpQueue.pop();
|
|
1426
|
+
if (popped !== undefined) this.#emitQueueChanged();
|
|
1427
|
+
return popped;
|
|
1359
1428
|
}
|
|
1360
1429
|
|
|
1361
1430
|
clearMessages() {
|
|
@@ -1750,6 +1819,7 @@ export class Agent {
|
|
|
1750
1819
|
transformAssistantMessage: this.transformAssistantMessage
|
|
1751
1820
|
? (message, signal) => this.transformAssistantMessage?.(message, signal)
|
|
1752
1821
|
: undefined,
|
|
1822
|
+
transformAssistantMessagePreservesToolCalls: this.transformAssistantMessagePreservesToolCalls,
|
|
1753
1823
|
onAssistantMessageEvent: this.#onAssistantMessageEvent,
|
|
1754
1824
|
onHarmonyLeak: this.#onHarmonyLeak,
|
|
1755
1825
|
onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { $env } from "@oh-my-pi/pi-utils";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
5
|
+
|
|
6
|
+
/** Azure OpenAI base URL for compaction requests: env override, resource name, then the model's baseUrl. */
|
|
7
|
+
export function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
8
|
+
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
9
|
+
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
10
|
+
const resolvedBaseUrl =
|
|
11
|
+
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
12
|
+
if (!resolvedBaseUrl) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Append `api-version` unless the endpoint already carries one. */
|
|
21
|
+
export function appendAzureApiVersion(endpoint: string): string {
|
|
22
|
+
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
23
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
24
|
+
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry";
|
|
2
|
+
import type { FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
|
|
3
|
+
import { transportFetch } from "@oh-my-pi/pi-ai/utils/transport-fetch";
|
|
4
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
5
|
+
|
|
6
|
+
export interface BedrockCompactionRequest {
|
|
7
|
+
model: Model;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
fetch: FetchImpl;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shape a native compaction request for Amazon Bedrock's OpenAI routes
|
|
14
|
+
* (`isBedrockOpenAIUrl`) the way stream dispatch shapes a normal Bedrock turn:
|
|
15
|
+
* configured headers, transport fetch (proxy, CA, User-Agent, request
|
|
16
|
+
* recording), then the provider's model and request hooks. The bundled
|
|
17
|
+
* `bedrock-mantle` provider's hook fills in `{region}` and picks bearer or
|
|
18
|
+
* SigV4 auth; without it compaction targets an unresolved host.
|
|
19
|
+
*
|
|
20
|
+
* Only Bedrock requests come through here; other providers' compaction
|
|
21
|
+
* requests keep their own transport.
|
|
22
|
+
*/
|
|
23
|
+
export async function prepareBedrockCompactionRequest(
|
|
24
|
+
model: Model,
|
|
25
|
+
apiKey: string,
|
|
26
|
+
fetch: FetchImpl | undefined,
|
|
27
|
+
signal: AbortSignal | undefined,
|
|
28
|
+
): Promise<BedrockCompactionRequest> {
|
|
29
|
+
let resolvedModel = model;
|
|
30
|
+
const resolveHeaders = model.resolveHeaders;
|
|
31
|
+
if (resolveHeaders) {
|
|
32
|
+
const headers = await untilAborted(signal, () => resolveHeaders(signal));
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
resolvedModel = { ...model, resolveHeaders: undefined, headers: headers ? { ...headers } : undefined };
|
|
35
|
+
}
|
|
36
|
+
const baseFetch = transportFetch(resolvedModel, fetch);
|
|
37
|
+
const provider = getProviderDefinition(resolvedModel.provider);
|
|
38
|
+
const providerModel = provider?.prepareModel?.(resolvedModel) ?? resolvedModel;
|
|
39
|
+
const prepared = provider?.prepareRequest?.(providerModel, { apiKey, fetch: baseFetch, signal }) ?? {
|
|
40
|
+
model: providerModel,
|
|
41
|
+
options: { apiKey, fetch: baseFetch },
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
model: { ...prepared.model, headers: { ...prepared.model.headers, ...prepared.options.headers } },
|
|
45
|
+
apiKey: prepared.options.apiKey || apiKey,
|
|
46
|
+
fetch: prepared.options.fetch ?? baseFetch,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveOpenAIRequestSetup,
|
|
25
25
|
} from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
26
26
|
import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
|
|
27
|
+
import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
27
28
|
import {
|
|
28
29
|
applyCodexResidencyHeader,
|
|
29
30
|
CODEX_BASE_URL,
|
|
@@ -32,7 +33,9 @@ import {
|
|
|
32
33
|
OPENAI_HEADER_VALUES,
|
|
33
34
|
OPENAI_HEADERS,
|
|
34
35
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
35
|
-
import { $env, isUnexpectedSocketCloseMessage, logger, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
36
|
+
import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
37
|
+
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
38
|
+
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
36
39
|
|
|
37
40
|
// ============================================================================
|
|
38
41
|
// Types & Configuration
|
|
@@ -47,7 +50,6 @@ export const V2_COMPACTION_MAX_RETRIES = 2;
|
|
|
47
50
|
/** Timeout for V2 streaming compaction (5 minutes, same as V1). */
|
|
48
51
|
export const V2_COMPACTION_TIMEOUT_MS = 300_000;
|
|
49
52
|
|
|
50
|
-
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
51
53
|
const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
52
54
|
const COMPACTION_TRIGGER_ITEM = { type: "compaction_trigger" } as const;
|
|
53
55
|
// OpenAI image metering depends on detail and dimensions; charge the common
|
|
@@ -96,7 +98,7 @@ export interface CompactionV2Response {
|
|
|
96
98
|
/** Resolve the streaming Responses endpoint for a V2-capable model. */
|
|
97
99
|
export function getCompactionV2Endpoint(model: Model): string | undefined {
|
|
98
100
|
if (model.remoteCompaction?.enabled === false) return undefined;
|
|
99
|
-
if (!
|
|
101
|
+
if (!isOpenAiRemoteCompactionApi(compactionV2Api(model))) return undefined;
|
|
100
102
|
|
|
101
103
|
const configuredEndpoint = model.remoteCompaction?.v2Endpoint ?? model.remoteCompaction?.streamingEndpoint;
|
|
102
104
|
if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
|
|
@@ -115,7 +117,15 @@ export function getCompactionV2Endpoint(model: Model): string | undefined {
|
|
|
115
117
|
export function shouldUseCompactionV2Streaming(
|
|
116
118
|
model: Model,
|
|
117
119
|
): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses"> {
|
|
118
|
-
|
|
120
|
+
const v2StreamingEnabled = model.remoteCompaction?.v2StreamingEnabled;
|
|
121
|
+
if (v2StreamingEnabled === false) return false;
|
|
122
|
+
// Amazon Bedrock's OpenAI routes accept `compaction_trigger` without an opt-in.
|
|
123
|
+
if (
|
|
124
|
+
v2StreamingEnabled !== true &&
|
|
125
|
+
!(compactionV2Api(model) === "openai-responses" && isBedrockOpenAIUrl(model.baseUrl))
|
|
126
|
+
) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
119
129
|
return getCompactionV2Endpoint(model) !== undefined;
|
|
120
130
|
}
|
|
121
131
|
|
|
@@ -123,8 +133,8 @@ function compactionV2Api(model: Model): Api | undefined {
|
|
|
123
133
|
return model.remoteCompaction?.api ?? model.api;
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
/** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
|
|
137
|
+
export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
128
138
|
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
129
139
|
}
|
|
130
140
|
|
|
@@ -152,25 +162,6 @@ function resolveOpenAiCodexResponsesEndpoint(baseUrl: string | undefined): strin
|
|
|
152
162
|
return `${normalizedBase}/codex/responses`;
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
156
|
-
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
157
|
-
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
158
|
-
const resolvedBaseUrl =
|
|
159
|
-
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
160
|
-
if (!resolvedBaseUrl) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function appendAzureApiVersion(endpoint: string): string {
|
|
169
|
-
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
170
|
-
const separator = endpoint.includes("?") ? "&" : "?";
|
|
171
|
-
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
165
|
function resolveCompactionV2Model(model: Model): string {
|
|
175
166
|
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
|
176
167
|
if (compactionV2Api(model) !== "azure-openai-responses") return requestModel;
|
|
@@ -248,13 +239,6 @@ export function buildCompactionV2RequestFromBody(
|
|
|
248
239
|
// Streaming Request Handler
|
|
249
240
|
// ============================================================================
|
|
250
241
|
|
|
251
|
-
/** Race the caller's signal against the V2 request timeout. */
|
|
252
|
-
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
253
|
-
if (timeoutMs <= 0) return signal;
|
|
254
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
255
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
242
|
/** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
|
|
259
243
|
export async function requestCompactionV2Streaming(
|
|
260
244
|
model: Model,
|
|
@@ -270,12 +254,19 @@ export async function requestCompactionV2Streaming(
|
|
|
270
254
|
preferWebsockets?: boolean;
|
|
271
255
|
},
|
|
272
256
|
): Promise<CompactionV2Response> {
|
|
257
|
+
let fetchImpl: FetchImpl = options?.fetch ?? globalThis.fetch;
|
|
258
|
+
if (isBedrockOpenAIUrl(model.baseUrl)) {
|
|
259
|
+
({
|
|
260
|
+
model,
|
|
261
|
+
apiKey,
|
|
262
|
+
fetch: fetchImpl,
|
|
263
|
+
} = await prepareBedrockCompactionRequest(model, apiKey, options?.fetch, signal));
|
|
264
|
+
}
|
|
273
265
|
const endpoint = getCompactionV2Endpoint(model);
|
|
274
266
|
if (!endpoint) {
|
|
275
267
|
throw new Error(`Model ${model.id} does not support V2 streaming compaction`);
|
|
276
268
|
}
|
|
277
269
|
|
|
278
|
-
const fetchImpl = options?.fetch ?? globalThis.fetch;
|
|
279
270
|
const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
|
|
280
271
|
const isCodexResponses = compactionV2Api(model) === "openai-codex-responses" || model.provider === "openai-codex";
|
|
281
272
|
const codexMetadata =
|
|
@@ -293,7 +284,7 @@ export async function requestCompactionV2Streaming(
|
|
|
293
284
|
let lastError: Error | undefined;
|
|
294
285
|
|
|
295
286
|
for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
|
|
296
|
-
const timeoutSignal =
|
|
287
|
+
const timeoutSignal = ptree.combineSignals(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
|
|
297
288
|
try {
|
|
298
289
|
return await attemptCompactionV2Streaming(endpoint, apiKey, model, request, fetchImpl, timeoutSignal, {
|
|
299
290
|
codexMetadata,
|
package/src/compaction/openai.ts
CHANGED
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
} from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
29
29
|
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
|
30
30
|
import type {
|
|
31
|
-
Api,
|
|
32
31
|
AssistantMessage,
|
|
33
32
|
CodexCompactionContext,
|
|
34
33
|
FetchImpl,
|
|
@@ -43,6 +42,7 @@ import {
|
|
|
43
42
|
stripOpenAIResponsesOutputOnlyStatusesForReplay,
|
|
44
43
|
} from "@oh-my-pi/pi-ai/utils";
|
|
45
44
|
import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
|
|
45
|
+
import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
46
46
|
import {
|
|
47
47
|
applyCodexResidencyHeader,
|
|
48
48
|
CODEX_BASE_URL,
|
|
@@ -51,8 +51,11 @@ import {
|
|
|
51
51
|
OPENAI_HEADER_VALUES,
|
|
52
52
|
OPENAI_HEADERS,
|
|
53
53
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
54
|
-
import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
54
|
+
import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
55
55
|
import { Tokenizer } from "../tokenizer";
|
|
56
|
+
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
57
|
+
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
58
|
+
import { isOpenAiRemoteCompactionApi } from "./compaction-v2-streaming";
|
|
56
59
|
import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
|
|
57
60
|
|
|
58
61
|
export * from "./compaction-v2-streaming";
|
|
@@ -73,8 +76,6 @@ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
|
73
76
|
*/
|
|
74
77
|
export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
|
|
75
78
|
|
|
76
|
-
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
77
|
-
|
|
78
79
|
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
79
80
|
|
|
80
81
|
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
@@ -110,6 +111,10 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
110
111
|
const normalized: Record<string, unknown> = {};
|
|
111
112
|
let imageTokens = 0;
|
|
112
113
|
for (const [key, item] of Object.entries(record)) {
|
|
114
|
+
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
115
|
+
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
116
|
+
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
117
|
+
if (key === "encrypted_content" && typeof item === "string") continue;
|
|
113
118
|
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
114
119
|
normalized[key] = result.value;
|
|
115
120
|
imageTokens += result.imageTokens;
|
|
@@ -137,9 +142,10 @@ interface RemoteCompactionBudgetProbe {
|
|
|
137
142
|
/**
|
|
138
143
|
* Cheap-first sizing of a remote-compaction request. Images and the request
|
|
139
144
|
* frame are charged flat, so they come off the budget rather than through the
|
|
140
|
-
* tokenizer;
|
|
141
|
-
* {@link Tokenizer.checkTokenBudget}, which only
|
|
142
|
-
* the byte bound cannot already prove the request
|
|
145
|
+
* tokenizer; opaque `encrypted_content` payloads are excluded. The serialized
|
|
146
|
+
* transcript is then probed with {@link Tokenizer.checkTokenBudget}, which only
|
|
147
|
+
* pays for an exact count when the byte bound cannot already prove the request
|
|
148
|
+
* fits.
|
|
143
149
|
*/
|
|
144
150
|
function probeRemoteCompactionInputBudget(
|
|
145
151
|
input: Array<Record<string, unknown>>,
|
|
@@ -257,13 +263,6 @@ export function assertRemoteCompactionInputFits(trimmed: TrimRemoteCompactionInp
|
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
|
261
|
-
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
262
|
-
if (timeoutMs <= 0) return signal;
|
|
263
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
264
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
266
|
export type OpenAiRemoteCompactionItem = {
|
|
268
267
|
type: "compaction" | "compaction_summary";
|
|
269
268
|
encrypted_content?: string;
|
|
@@ -304,10 +303,6 @@ export interface RemoteCompactionResponse {
|
|
|
304
303
|
// OpenAI provider gating + endpoint resolution
|
|
305
304
|
// ============================================================================
|
|
306
305
|
|
|
307
|
-
export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
308
|
-
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
309
|
-
}
|
|
310
|
-
|
|
311
306
|
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
312
307
|
if (model.remoteCompaction?.enabled === false) return false;
|
|
313
308
|
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
@@ -318,6 +313,8 @@ export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
|
318
313
|
return (model.remoteCompaction?.endpoint?.trim().length ?? 0) > 0;
|
|
319
314
|
}
|
|
320
315
|
if (model.provider === "openai") return true;
|
|
316
|
+
// Amazon Bedrock's OpenAI routes serve `/responses/compact` without an opt-in.
|
|
317
|
+
if (compactionApi === "openai-responses" && isBedrockOpenAIUrl(model.baseUrl)) return true;
|
|
321
318
|
if (model.remoteCompaction?.enabled !== true) return false;
|
|
322
319
|
return isOpenAiRemoteCompactionApi(compactionApi);
|
|
323
320
|
}
|
|
@@ -348,25 +345,6 @@ function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: str
|
|
|
348
345
|
return appendAzureApiVersion(endpoint);
|
|
349
346
|
}
|
|
350
347
|
|
|
351
|
-
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
352
|
-
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
353
|
-
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
354
|
-
const resolvedBaseUrl =
|
|
355
|
-
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
356
|
-
if (!resolvedBaseUrl) {
|
|
357
|
-
throw new Error(
|
|
358
|
-
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function appendAzureApiVersion(endpoint: string): string {
|
|
365
|
-
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
366
|
-
const separator = endpoint.includes("?") ? "&" : "?";
|
|
367
|
-
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
348
|
function resolveOpenAiCompactModel(model: Model): string {
|
|
371
349
|
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
|
372
350
|
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
@@ -800,6 +778,10 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
800
778
|
codexCompaction?: CodexCompactionContext;
|
|
801
779
|
},
|
|
802
780
|
): Promise<OpenAiRemoteCompactionResponse> {
|
|
781
|
+
let fetchImpl: FetchImpl = opts?.fetch ?? fetch;
|
|
782
|
+
if (isBedrockOpenAIUrl(model.baseUrl)) {
|
|
783
|
+
({ model, apiKey, fetch: fetchImpl } = await prepareBedrockCompactionRequest(model, apiKey, opts?.fetch, signal));
|
|
784
|
+
}
|
|
803
785
|
const endpoint = resolveOpenAiCompactEndpoint(model);
|
|
804
786
|
const requestModel = resolveOpenAiCompactModel(model);
|
|
805
787
|
const trimmed = trimRemoteCompactionInputToContextWindow(
|
|
@@ -884,11 +866,11 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
884
866
|
}
|
|
885
867
|
}
|
|
886
868
|
|
|
887
|
-
const response = await (
|
|
869
|
+
const response = await fetchImpl(endpoint, {
|
|
888
870
|
method: "POST",
|
|
889
871
|
headers,
|
|
890
872
|
body: stringifyJson(request),
|
|
891
|
-
signal:
|
|
873
|
+
signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
892
874
|
});
|
|
893
875
|
|
|
894
876
|
if (!response.ok) {
|
|
@@ -987,7 +969,7 @@ export async function requestRemoteCompaction(
|
|
|
987
969
|
method: "POST",
|
|
988
970
|
headers,
|
|
989
971
|
body: stringifyJson(body),
|
|
990
|
-
signal:
|
|
972
|
+
signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
991
973
|
});
|
|
992
974
|
|
|
993
975
|
if (!response.ok) {
|
|
@@ -9,6 +9,7 @@ import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
|
9
9
|
import { invalidateMessageCache } from "./message-cache";
|
|
10
10
|
import {
|
|
11
11
|
collectToolCallsById,
|
|
12
|
+
getToolResultMessage,
|
|
12
13
|
isProtectedToolResult,
|
|
13
14
|
isSkillReadToolResult,
|
|
14
15
|
type ProtectedToolMatcher,
|
|
@@ -122,13 +123,6 @@ function createPrunedNotice(tokens: number): string {
|
|
|
122
123
|
*/
|
|
123
124
|
export const MIN_PRUNE_TOKENS = 50;
|
|
124
125
|
|
|
125
|
-
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
126
|
-
if (entry.type !== "message") return undefined;
|
|
127
|
-
const message = entry.message as AgentMessage;
|
|
128
|
-
if (message.role !== "toolResult") return undefined;
|
|
129
|
-
return message as ToolResultMessage;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
126
|
function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
133
127
|
const noticeTokens = Math.ceil(notice.length / 4);
|
|
134
128
|
return Math.max(0, tokens - noticeTokens);
|
package/src/compaction/shake.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./en
|
|
|
18
18
|
import { invalidateMessageCache } from "./message-cache";
|
|
19
19
|
import {
|
|
20
20
|
collectToolCallsById,
|
|
21
|
+
getToolResultMessage,
|
|
21
22
|
isArtifactRecoveryToolResult,
|
|
22
23
|
isProtectedToolResult,
|
|
23
24
|
isSkillReadToolResult,
|
|
@@ -109,13 +110,6 @@ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
|
|
|
109
110
|
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
110
111
|
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
111
112
|
|
|
112
|
-
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
113
|
-
if (entry.type !== "message") return undefined;
|
|
114
|
-
const message = entry.message as AgentMessage;
|
|
115
|
-
if (message.role !== "toolResult") return undefined;
|
|
116
|
-
return message as ToolResultMessage;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
113
|
function toolResultText(
|
|
120
114
|
message: ToolResultMessage,
|
|
121
115
|
tokenizer: Tokenizer,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
2
|
-
import type { AgentToolCall } from "../types";
|
|
2
|
+
import type { AgentMessage, AgentToolCall } from "../types";
|
|
3
3
|
import type { SessionEntry } from "./entries";
|
|
4
4
|
|
|
5
5
|
export interface ProtectedToolContext {
|
|
@@ -24,6 +24,14 @@ export function collectToolCallsById(entries: readonly SessionEntry[]): Map<stri
|
|
|
24
24
|
return toolCalls;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Return the tool-result message carried by a session entry, if any. */
|
|
28
|
+
export function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
29
|
+
if (entry.type !== "message") return undefined;
|
|
30
|
+
const message = entry.message as AgentMessage;
|
|
31
|
+
if (message.role !== "toolResult") return undefined;
|
|
32
|
+
return message as ToolResultMessage;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* Extract the `path` argument from a paired `read` tool call, when the result
|
|
29
37
|
* is a `read` result carrying a string path. Returns `undefined` otherwise.
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SpeculativeChildDefinition,
|
|
11
11
|
SpeculativeChildHandle,
|
|
12
12
|
SpeculativeCommitContext,
|
|
13
|
+
SpeculativeLaunchContext,
|
|
13
14
|
SpeculativeOperationContext,
|
|
14
15
|
SpeculativePhysicalOutcome,
|
|
15
16
|
SpeculativeResourceAccess,
|
|
@@ -520,6 +521,17 @@ export class SpeculativeOperationCoordinator {
|
|
|
520
521
|
}
|
|
521
522
|
}
|
|
522
523
|
|
|
524
|
+
async authorizeLaunch(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization> {
|
|
525
|
+
if (this.#closed) return { allowed: false, reason: "speculation coordinator is closed" };
|
|
526
|
+
const authorize = this.config.host?.authorizeLaunch;
|
|
527
|
+
if (!authorize) return { allowed: false, reason: "host does not authorize speculative launches" };
|
|
528
|
+
try {
|
|
529
|
+
return await authorize.call(this.config.host, context);
|
|
530
|
+
} catch {
|
|
531
|
+
return { allowed: false, reason: "host launch authorization failed" };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
523
535
|
async discardChildren(parentToolCallId: string, reason: string): Promise<void> {
|
|
524
536
|
await this.#admission;
|
|
525
537
|
await Promise.all(
|
package/src/telemetry.ts
CHANGED
|
@@ -722,7 +722,7 @@ export function startChatSpan(
|
|
|
722
722
|
model,
|
|
723
723
|
parent: options.parent,
|
|
724
724
|
stepNumber: options.stepNumber,
|
|
725
|
-
attributes: buildChatRequestAttributes(options.stepNumber, options.request, model
|
|
725
|
+
attributes: buildChatRequestAttributes(options.stepNumber, options.request, model),
|
|
726
726
|
});
|
|
727
727
|
if (span) {
|
|
728
728
|
telemetry?.collector.beginChat(span, {
|
|
@@ -756,7 +756,7 @@ export interface ChatRequestSnapshot {
|
|
|
756
756
|
readonly messages?: readonly Message[];
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
-
function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot,
|
|
759
|
+
function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot, model: Model): Attributes {
|
|
760
760
|
const attrs: Attributes = {
|
|
761
761
|
[OmpGenAIAttr.AgentStepNumber]: stepNumber,
|
|
762
762
|
[GenAIAttr.OutputType]: "text",
|
|
@@ -772,7 +772,8 @@ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnap
|
|
|
772
772
|
if (request.stopSequences && request.stopSequences.length > 0) {
|
|
773
773
|
attrs[GenAIAttr.RequestStopSequences] = [...request.stopSequences];
|
|
774
774
|
}
|
|
775
|
-
|
|
775
|
+
// Record the tier only when it reaches the wire for this model (Codex drops unadvertised tiers).
|
|
776
|
+
if (request.serviceTier && shouldSendServiceTier(request.serviceTier, model)) {
|
|
776
777
|
attrs[OpenAIAttr.RequestServiceTier] = request.serviceTier;
|
|
777
778
|
}
|
|
778
779
|
if (request.reasoningEffort) attrs[OmpGenAIAttr.RequestReasoningEffort] = request.reasoningEffort;
|
package/src/tool-context.ts
CHANGED
|
@@ -24,13 +24,19 @@ export function isNonBlankContext(value: unknown): value is string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Join passive context values in order, dropping blanks
|
|
28
|
-
*
|
|
27
|
+
* Join passive context values in order, dropping blanks and repeats of an
|
|
28
|
+
* earlier value (compared without surrounding whitespace; the first original
|
|
29
|
+
* is kept). Returns undefined when nothing remains.
|
|
29
30
|
*/
|
|
30
31
|
export function joinAdditionalContext(values: Iterable<string | undefined>): string | undefined {
|
|
32
|
+
const seen = new Set<string>();
|
|
31
33
|
const kept: string[] = [];
|
|
32
34
|
for (const value of values) {
|
|
33
|
-
if (isNonBlankContext(value))
|
|
35
|
+
if (!isNonBlankContext(value)) continue;
|
|
36
|
+
const key = value.trim();
|
|
37
|
+
if (seen.has(key)) continue;
|
|
38
|
+
seen.add(key);
|
|
39
|
+
kept.push(value);
|
|
34
40
|
}
|
|
35
41
|
return kept.length > 0 ? kept.join("\n\n") : undefined;
|
|
36
42
|
}
|
package/src/types.ts
CHANGED
|
@@ -595,6 +595,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
595
595
|
*/
|
|
596
596
|
transformAssistantMessage?: (message: AssistantMessage, signal?: AbortSignal) => Promise<void> | void;
|
|
597
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Declares that {@link transformAssistantMessage} never rewrites or removes a
|
|
600
|
+
* tool call the model streamed (it may edit text or append new calls). Stream
|
|
601
|
+
* speculation sessions and direct speculative candidates plan from streamed
|
|
602
|
+
* calls, so they stay disabled under a transform unless this is set.
|
|
603
|
+
*/
|
|
604
|
+
transformAssistantMessagePreservesToolCalls?: boolean;
|
|
605
|
+
|
|
598
606
|
/**
|
|
599
607
|
* Called after a tool finishes executing, before `tool_execution_end` and the
|
|
600
608
|
* tool-result message are emitted.
|
|
@@ -740,9 +748,24 @@ export interface SpeculativeExecutionHost {
|
|
|
740
748
|
commitDefault: () => Promise<AgentToolResult<unknown>>,
|
|
741
749
|
): Promise<SpeculativeCommitDecision>;
|
|
742
750
|
discard?(context: SpeculativeDiscardContext): void | Promise<void>;
|
|
751
|
+
/**
|
|
752
|
+
* Authorize a stream session to start effectful work (e.g. subagents) from
|
|
753
|
+
* partially streamed arguments. The session owns that work and must abort it
|
|
754
|
+
* when the finalized call is invalid, blocked, or changed. Hosts without this
|
|
755
|
+
* hook deny every launch.
|
|
756
|
+
*/
|
|
757
|
+
authorizeLaunch?(context: SpeculativeLaunchContext): SpeculativeAuthorization | Promise<SpeculativeAuthorization>;
|
|
743
758
|
close?(reason: string): void | Promise<void>;
|
|
744
759
|
}
|
|
745
760
|
|
|
761
|
+
/** Effectful work a tool-owned stream session asks to start before its outer call dispatches. */
|
|
762
|
+
export interface SpeculativeLaunchContext {
|
|
763
|
+
tool: SpeculativeToolReference;
|
|
764
|
+
toolCall: AgentToolCall;
|
|
765
|
+
/** Arguments the launch was planned from: the streamed prefix of the outer call. */
|
|
766
|
+
args: Readonly<Record<string, unknown>>;
|
|
767
|
+
}
|
|
768
|
+
|
|
746
769
|
export interface ToolSpeculationStreamContext {
|
|
747
770
|
readonly coordinator: SpeculativeOperationSink;
|
|
748
771
|
readonly parentToolCallId: string;
|
|
@@ -789,6 +812,8 @@ export interface ToolSpeculationStreamSession {
|
|
|
789
812
|
export interface SpeculativeOperationSink {
|
|
790
813
|
readonly maxInFlight: number;
|
|
791
814
|
admit(definition: SpeculativeChildDefinition): Promise<SpeculativeChildHandle | undefined>;
|
|
815
|
+
/** Host-gated permission for effectful stream work; see {@link SpeculativeExecutionHost.authorizeLaunch}. */
|
|
816
|
+
authorizeLaunch?(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization>;
|
|
792
817
|
discardChildren?(parentToolCallId: string, reason: string): void | Promise<void>;
|
|
793
818
|
close(reason: string): void | Promise<void>;
|
|
794
819
|
}
|
|
@@ -850,7 +875,8 @@ export interface SpeculativeToolExecutionConfig {
|
|
|
850
875
|
* ignored when `block` is true.
|
|
851
876
|
*
|
|
852
877
|
* Set `additionalContext` to attach passive model-visible context to this call.
|
|
853
|
-
* Non-empty values from a tool batch are injected in assistant tool-call order
|
|
878
|
+
* Non-empty values from a tool batch are injected in assistant tool-call order,
|
|
879
|
+
* a value identical to an earlier one in the batch only once,
|
|
854
880
|
* after every result settles and before the next provider request. It is
|
|
855
881
|
* dropped when the call is blocked or skipped, or when its final result is an
|
|
856
882
|
* error (including an approval denial raised by the tool's own gate). Within a
|