@linxiraos/pi-agent-core 1.1.22 → 1.1.24
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 +2 -2
- package/README.md +4 -0
- package/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/types/agent.d.ts +29 -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 +3 -4
- package/dist/types/compaction/pruning.d.ts +6 -0
- package/dist/types/compaction/tool-protection.d.ts +2 -0
- package/dist/types/types.d.ts +8 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +7 -3
- package/src/agent.ts +102 -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 -41
- package/src/compaction/pruning.ts +40 -25
- package/src/compaction/shake.ts +1 -7
- package/src/compaction/tool-protection.ts +9 -1
- package/src/proxy.ts +3 -2
- package/src/speculative-execution.ts +3 -3
- package/src/telemetry.ts +4 -3
- package/src/types.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [1.1.
|
|
5
|
+
## [1.1.24] - 2026-10-03
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- 上游 v18.4.11 集成:goal 命令与 Zeta goal/vibe mode 共存(GoalRuntime 共享);跨面 token 对双端清扫(统一 `__zeta_worker_*`)。
|
|
8
8
|
|
|
9
9
|
## [14.9.5] - 2026-05-12
|
|
10
10
|
|
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/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
ZETA THIRD-PARTY NOTICES
|
|
2
2
|
=======================
|
|
3
3
|
|
|
4
|
-
This generated, self-contained aggregate accompanies
|
|
4
|
+
This generated, self-contained aggregate accompanies Zeta release artifacts.
|
|
5
5
|
It does not replace package-level license files. First-party licensing grants
|
|
6
6
|
no rights in third-party code, fonts, data, or marks identified below.
|
|
7
7
|
|
|
@@ -1046,7 +1046,7 @@ These packages participate only in generation/web build tooling and are not ship
|
|
|
1046
1046
|
|
|
1047
1047
|
SCOPED NON-PERMISSIVE PACKAGES
|
|
1048
1048
|
------------------------------
|
|
1049
|
-
The exact @img/sharp-libvips-* 1.2.4 all-target family is LGPL-3.0-or-later. It is not distributed in
|
|
1049
|
+
The exact @img/sharp-libvips-* 1.2.4 all-target family is LGPL-3.0-or-later. It is not distributed in Zeta npm bundles or standalone assets: @huggingface/transformers is always external, installed into a user-side versioned runtime on demand, and sharp is stubbed for speech/text. The dynamically loaded native library remains replaceable. Corresponding source is available from https://github.com/lovell/sharp-libvips.git and upstream libvips sources.
|
|
1050
1050
|
|
|
1051
1051
|
lightningcss 1.32.0 and its exact target leaves are MPL-2.0 build-only dependencies. MPL is not globally allowed and these packages are absent from runtime artifacts.
|
|
1052
1052
|
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -411,6 +411,12 @@ export declare class Agent {
|
|
|
411
411
|
*/
|
|
412
412
|
buildSideRequestContext(llmMessages: Message[], systemPrompt?: string[]): Promise<Context>;
|
|
413
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;
|
|
414
420
|
/** Register an independently removable hook that runs before queued messages are consumed. */
|
|
415
421
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
416
422
|
/** Register an independently removable hook that runs immediately before each model call. */
|
|
@@ -461,6 +467,23 @@ export declare class Agent {
|
|
|
461
467
|
setTools(t: AgentTool<any>[]): void;
|
|
462
468
|
replaceMessages(ms: AgentMessage[]): void;
|
|
463
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;
|
|
480
|
+
/**
|
|
481
|
+
* Install `followUp` as the pending follow-up queue (same claim handling as
|
|
482
|
+
* {@link replaceQueue}) and append `promoted` to the steering queue, then notify
|
|
483
|
+
* queue listeners once. A live steering claim is left alone. Listeners never
|
|
484
|
+
* observe the moved messages in both queues or in neither.
|
|
485
|
+
*/
|
|
486
|
+
moveFollowUpsToSteering(followUp: readonly AgentMessage[], promoted: readonly AgentMessage[]): void;
|
|
464
487
|
appendMessage(m: AgentMessage): void;
|
|
465
488
|
popMessage(): AgentMessage | undefined;
|
|
466
489
|
/**
|
|
@@ -499,6 +522,12 @@ export declare class Agent {
|
|
|
499
522
|
/** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
|
|
500
523
|
getPendingToolResults(): readonly ToolResultMessage[];
|
|
501
524
|
get isAborting(): boolean;
|
|
525
|
+
/**
|
|
526
|
+
* Join adjacent queued records into one delivery unit in one-at-a-time mode.
|
|
527
|
+
* The predicate must be synchronous and side-effect-free. By default each
|
|
528
|
+
* record is independent; all mode, queue inspection, and removal stay unchanged.
|
|
529
|
+
*/
|
|
530
|
+
setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void;
|
|
502
531
|
/**
|
|
503
532
|
* Remove and return the last steering message from the queue (LIFO).
|
|
504
533
|
* Used by dequeue keybinding.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Model } from "@linxiraos/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 "@linxiraos/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 "@linxiraos/pi-ai";
|
|
9
|
+
import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@linxiraos/pi-ai";
|
|
10
10
|
import { type OpenAICodexCompactionBody } from "@linxiraos/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 "@linxiraos/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;
|
|
@@ -110,10 +109,10 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
|
|
|
110
109
|
/**
|
|
111
110
|
* Generic remote-compaction POST. Two wire shapes are auto-selected by
|
|
112
111
|
* endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
|
|
113
|
-
* either a purpose-built
|
|
112
|
+
* either a purpose-built zeta summarizer (`{systemPrompt, prompt}` → `{summary}`)
|
|
114
113
|
* or any OpenAI-compatible chat-completions server (`/chat/completions`,
|
|
115
114
|
* `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
|
|
116
|
-
* issue #4630: without this, the
|
|
115
|
+
* issue #4630: without this, the zeta payload was rejected with
|
|
117
116
|
* HTTP 400 `"'messages' is required"`, compaction silently fell back to
|
|
118
117
|
* local summarization, and context grew unbounded.
|
|
119
118
|
*
|
|
@@ -43,6 +43,12 @@ export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
|
|
|
43
43
|
export interface PruneResult {
|
|
44
44
|
prunedCount: number;
|
|
45
45
|
tokensSaved: number;
|
|
46
|
+
/**
|
|
47
|
+
* Restore every result this pass blanked. Pruning mutates entries in place,
|
|
48
|
+
* so a caller whose persistence of the pruned history fails calls this to keep
|
|
49
|
+
* memory matching what is durable.
|
|
50
|
+
*/
|
|
51
|
+
undo(): void;
|
|
46
52
|
}
|
|
47
53
|
/** Exact placeholder written over a superseded tool result. */
|
|
48
54
|
export declare const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
|
|
@@ -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.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -808,6 +808,13 @@ export interface AfterToolCallResult {
|
|
|
808
808
|
isError?: boolean;
|
|
809
809
|
/** If provided, replaces the contextually-useless flag carried with the tool result. */
|
|
810
810
|
useless?: boolean;
|
|
811
|
+
/**
|
|
812
|
+
* Trusted post-tool instructions for the next provider request. Delivered
|
|
813
|
+
* outside the tool result, after all calls in the batch settle. Unlike
|
|
814
|
+
* `BeforeToolCallResult.additionalContext`, this is retained for error
|
|
815
|
+
* results because the callback receives the finalized outcome.
|
|
816
|
+
*/
|
|
817
|
+
additionalContext?: string;
|
|
811
818
|
}
|
|
812
819
|
/** Context passed to `beforeToolCall`. */
|
|
813
820
|
export interface BeforeToolCallContext {
|
|
@@ -1000,7 +1007,7 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|
|
1000
1007
|
* before ordinary dispatch commits its result.
|
|
1001
1008
|
*/
|
|
1002
1009
|
speculation?: ToolSpeculationPolicy;
|
|
1003
|
-
/** If true,
|
|
1010
|
+
/** If true, schema validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. Malformed argument JSON still returns the parse error. */
|
|
1004
1011
|
lenientArgValidation?: boolean;
|
|
1005
1012
|
/**
|
|
1006
1013
|
* Whether the agent loop may abort this tool mid-execution — or skip it
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-agent-core",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.24",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
7
|
"author": "Linxira-OS",
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@linxiraos/pi-ai": "1.1.
|
|
39
|
-
"@linxiraos/pi-catalog": "1.1.
|
|
40
|
-
"@linxiraos/pi-natives": "1.1.
|
|
41
|
-
"@linxiraos/pi-utils": "1.1.
|
|
42
|
-
"@linxiraos/pi-wire": "1.1.
|
|
43
|
-
"@linxiraos/pi-snapcompact": "1.1.
|
|
38
|
+
"@linxiraos/pi-ai": "1.1.24",
|
|
39
|
+
"@linxiraos/pi-catalog": "1.1.24",
|
|
40
|
+
"@linxiraos/pi-natives": "1.1.24",
|
|
41
|
+
"@linxiraos/pi-utils": "1.1.24",
|
|
42
|
+
"@linxiraos/pi-wire": "1.1.24",
|
|
43
|
+
"@linxiraos/pi-snapcompact": "1.1.24",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@linxiraos/pi-omptype": "1.1.
|
|
47
|
+
"@linxiraos/pi-omptype": "1.1.24",
|
|
48
48
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
49
49
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
50
50
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -2907,13 +2907,14 @@ async function prepareToolCallDispatch(
|
|
|
2907
2907
|
}
|
|
2908
2908
|
return validateToolArguments(tool, { ...toolCall, arguments: args });
|
|
2909
2909
|
} catch (validationError) {
|
|
2910
|
-
|
|
2910
|
+
// Lenience covers schema mismatches; a parse failure has no args to hand over.
|
|
2911
|
+
const parseFailed = "__parseError" in args;
|
|
2912
|
+
if (tool?.lenientArgValidation && !parseFailed) {
|
|
2911
2913
|
const fallback = { ...args };
|
|
2912
|
-
delete fallback.__parseError;
|
|
2913
2914
|
delete fallback.__rawJson;
|
|
2914
2915
|
return fallback;
|
|
2915
2916
|
}
|
|
2916
|
-
entry.args =
|
|
2917
|
+
entry.args = parseFailed ? { __parseError: args.__parseError } : args;
|
|
2917
2918
|
entry.validationErrorMessage =
|
|
2918
2919
|
validationError instanceof Error ? validationError.message : String(validationError);
|
|
2919
2920
|
return undefined;
|
|
@@ -3470,6 +3471,9 @@ async function executeToolCalls(
|
|
|
3470
3471
|
});
|
|
3471
3472
|
result = coerced.result;
|
|
3472
3473
|
isError = coerced.malformed || (after.isError ?? isError);
|
|
3474
|
+
if (isNonBlankContext(after.additionalContext)) {
|
|
3475
|
+
record.reportedContext.push(after.additionalContext);
|
|
3476
|
+
}
|
|
3473
3477
|
}
|
|
3474
3478
|
} catch (e) {
|
|
3475
3479
|
caughtError = e;
|
package/src/agent.ts
CHANGED
|
@@ -406,6 +406,7 @@ export class Agent {
|
|
|
406
406
|
};
|
|
407
407
|
#tokenizer = new Tokenizer(this.#state.model);
|
|
408
408
|
#listeners = new Set<(e: AgentEvent) => void>();
|
|
409
|
+
#queueListeners = new Set<() => void>();
|
|
409
410
|
#abortController?: AbortController;
|
|
410
411
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
411
412
|
#transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
|
@@ -430,6 +431,7 @@ export class Agent {
|
|
|
430
431
|
next: number;
|
|
431
432
|
}>();
|
|
432
433
|
#steeringWaiters = new Set<() => void>();
|
|
434
|
+
#queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
|
|
433
435
|
|
|
434
436
|
#steeringMode: "all" | "one-at-a-time";
|
|
435
437
|
#followUpMode: "all" | "one-at-a-time";
|
|
@@ -914,6 +916,16 @@ export class Agent {
|
|
|
914
916
|
return () => this.#listeners.delete(fn);
|
|
915
917
|
}
|
|
916
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
|
+
|
|
917
929
|
/** Register an independently removable hook that runs before queued messages are consumed. */
|
|
918
930
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
|
|
919
931
|
const registration = (signal?: AbortSignal) => hook(signal);
|
|
@@ -1001,6 +1013,7 @@ export class Agent {
|
|
|
1001
1013
|
} else {
|
|
1002
1014
|
this.#followUpQueue = [...claim.messages, ...this.#followUpQueue];
|
|
1003
1015
|
}
|
|
1016
|
+
this.#emitQueueChanged();
|
|
1004
1017
|
}
|
|
1005
1018
|
claim.controller.abort();
|
|
1006
1019
|
}
|
|
@@ -1026,6 +1039,7 @@ export class Agent {
|
|
|
1026
1039
|
this.#notifySteeringWaiters();
|
|
1027
1040
|
}
|
|
1028
1041
|
if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
|
|
1042
|
+
if (restored.steering.length > 0 || restored.followUp.length > 0) this.#emitQueueChanged();
|
|
1029
1043
|
}
|
|
1030
1044
|
|
|
1031
1045
|
/** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
|
|
@@ -1191,12 +1205,65 @@ export class Agent {
|
|
|
1191
1205
|
this.#state.messages = ms.slice();
|
|
1192
1206
|
}
|
|
1193
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
|
+
|
|
1194
1225
|
replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
|
|
1195
1226
|
this.#steeringQueue = steering.slice();
|
|
1196
1227
|
this.#followUpQueue = followUp.slice();
|
|
1197
1228
|
this.#cancelQueuedMessagePreparation("steering");
|
|
1198
1229
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1199
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();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Install `followUp` as the pending follow-up queue (same claim handling as
|
|
1257
|
+
* {@link replaceQueue}) and append `promoted` to the steering queue, then notify
|
|
1258
|
+
* queue listeners once. A live steering claim is left alone. Listeners never
|
|
1259
|
+
* observe the moved messages in both queues or in neither.
|
|
1260
|
+
*/
|
|
1261
|
+
moveFollowUpsToSteering(followUp: readonly AgentMessage[], promoted: readonly AgentMessage[]): void {
|
|
1262
|
+
this.#followUpQueue = followUp.slice();
|
|
1263
|
+
this.#cancelQueuedMessagePreparation("followUp");
|
|
1264
|
+
this.#steeringQueue.push(...promoted);
|
|
1265
|
+
this.#notifySteeringWaiters();
|
|
1266
|
+
this.#emitQueueChanged();
|
|
1200
1267
|
}
|
|
1201
1268
|
|
|
1202
1269
|
appendMessage(m: AgentMessage) {
|
|
@@ -1228,6 +1295,7 @@ export class Agent {
|
|
|
1228
1295
|
steer(m: AgentMessage) {
|
|
1229
1296
|
this.#steeringQueue.push(m);
|
|
1230
1297
|
this.#notifySteeringWaiters();
|
|
1298
|
+
this.#emitQueueChanged();
|
|
1231
1299
|
}
|
|
1232
1300
|
|
|
1233
1301
|
/**
|
|
@@ -1236,17 +1304,20 @@ export class Agent {
|
|
|
1236
1304
|
*/
|
|
1237
1305
|
followUp(m: AgentMessage) {
|
|
1238
1306
|
this.#followUpQueue.push(m);
|
|
1307
|
+
this.#emitQueueChanged();
|
|
1239
1308
|
}
|
|
1240
1309
|
|
|
1241
1310
|
clearSteeringQueue() {
|
|
1242
1311
|
this.#steeringQueue = [];
|
|
1243
1312
|
this.#cancelQueuedMessagePreparation("steering");
|
|
1244
1313
|
this.#notifySteeringWaiters();
|
|
1314
|
+
this.#emitQueueChanged();
|
|
1245
1315
|
}
|
|
1246
1316
|
|
|
1247
1317
|
clearFollowUpQueue() {
|
|
1248
1318
|
this.#followUpQueue = [];
|
|
1249
1319
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1320
|
+
this.#emitQueueChanged();
|
|
1250
1321
|
}
|
|
1251
1322
|
|
|
1252
1323
|
/**
|
|
@@ -1266,6 +1337,7 @@ export class Agent {
|
|
|
1266
1337
|
this.#cancelQueuedMessagePreparation("followUp");
|
|
1267
1338
|
this.#notifySteeringWaiters();
|
|
1268
1339
|
this.clearDeferredToolDirectives();
|
|
1340
|
+
this.#emitQueueChanged();
|
|
1269
1341
|
}
|
|
1270
1342
|
|
|
1271
1343
|
hasQueuedMessages(): boolean {
|
|
@@ -1317,32 +1389,34 @@ export class Agent {
|
|
|
1317
1389
|
return this.#abortController?.signal.aborted === true && this.#state.isStreaming;
|
|
1318
1390
|
}
|
|
1319
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* Join adjacent queued records into one delivery unit in one-at-a-time mode.
|
|
1394
|
+
* The predicate must be synchronous and side-effect-free. By default each
|
|
1395
|
+
* record is independent; all mode, queue inspection, and removal stay unchanged.
|
|
1396
|
+
*/
|
|
1397
|
+
setQueuedMessageGrouping(predicate: ((previous: AgentMessage, next: AgentMessage) => boolean) | undefined): void {
|
|
1398
|
+
this.#queuedMessageGrouping = predicate;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
#dequeueMessages(queue: readonly AgentMessage[], mode: "all" | "one-at-a-time"): AgentMessage[] {
|
|
1402
|
+
if (mode === "all") return queue.slice();
|
|
1403
|
+
let count = Math.min(1, queue.length);
|
|
1404
|
+
while (count < queue.length && this.#queuedMessageGrouping?.(queue[count - 1], queue[count])) count++;
|
|
1405
|
+
return queue.slice(0, count);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1320
1408
|
#dequeueSteeringMessages(): AgentMessage[] {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
return [first];
|
|
1326
|
-
}
|
|
1327
|
-
return [];
|
|
1328
|
-
}
|
|
1329
|
-
const steering = this.#steeringQueue.slice();
|
|
1330
|
-
this.#steeringQueue = [];
|
|
1331
|
-
return steering;
|
|
1409
|
+
const messages = this.#dequeueMessages(this.#steeringQueue, this.#steeringMode);
|
|
1410
|
+
this.#steeringQueue = this.#steeringQueue.slice(messages.length);
|
|
1411
|
+
if (messages.length > 0) this.#emitQueueChanged();
|
|
1412
|
+
return messages;
|
|
1332
1413
|
}
|
|
1333
1414
|
|
|
1334
1415
|
#dequeueFollowUpMessages(): AgentMessage[] {
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
return [first];
|
|
1340
|
-
}
|
|
1341
|
-
return [];
|
|
1342
|
-
}
|
|
1343
|
-
const followUp = this.#followUpQueue.slice();
|
|
1344
|
-
this.#followUpQueue = [];
|
|
1345
|
-
return followUp;
|
|
1416
|
+
const messages = this.#dequeueMessages(this.#followUpQueue, this.#followUpMode);
|
|
1417
|
+
this.#followUpQueue = this.#followUpQueue.slice(messages.length);
|
|
1418
|
+
if (messages.length > 0) this.#emitQueueChanged();
|
|
1419
|
+
return messages;
|
|
1346
1420
|
}
|
|
1347
1421
|
|
|
1348
1422
|
/**
|
|
@@ -1351,7 +1425,9 @@ export class Agent {
|
|
|
1351
1425
|
*/
|
|
1352
1426
|
popLastSteer(): AgentMessage | undefined {
|
|
1353
1427
|
if (this.#steeringQueue.length === 0) this.#cancelQueuedMessagePreparation("steering", true);
|
|
1354
|
-
|
|
1428
|
+
const popped = this.#steeringQueue.pop();
|
|
1429
|
+
if (popped !== undefined) this.#emitQueueChanged();
|
|
1430
|
+
return popped;
|
|
1355
1431
|
}
|
|
1356
1432
|
|
|
1357
1433
|
/**
|
|
@@ -1360,7 +1436,9 @@ export class Agent {
|
|
|
1360
1436
|
*/
|
|
1361
1437
|
popLastFollowUp(): AgentMessage | undefined {
|
|
1362
1438
|
if (this.#followUpQueue.length === 0) this.#cancelQueuedMessagePreparation("followUp", true);
|
|
1363
|
-
|
|
1439
|
+
const popped = this.#followUpQueue.pop();
|
|
1440
|
+
if (popped !== undefined) this.#emitQueueChanged();
|
|
1441
|
+
return popped;
|
|
1364
1442
|
}
|
|
1365
1443
|
|
|
1366
1444
|
clearMessages() {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Model } from "@linxiraos/pi-ai";
|
|
2
|
+
import { $env } from "@linxiraos/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 "@linxiraos/pi-ai/registry";
|
|
2
|
+
import type { FetchImpl, Model } from "@linxiraos/pi-ai/types";
|
|
3
|
+
import { transportFetch } from "@linxiraos/pi-ai/utils/transport-fetch";
|
|
4
|
+
import { untilAborted } from "@linxiraos/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 "@linxiraos/pi-ai/providers/openai-shared";
|
|
26
26
|
import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
|
|
27
|
+
import { isBedrockOpenAIUrl } from "@linxiraos/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 "@linxiraos/pi-catalog/wire/codex";
|
|
35
|
-
import { $env, isUnexpectedSocketCloseMessage, logger, stringifyJson } from "@linxiraos/pi-utils";
|
|
36
|
+
import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@linxiraos/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 "@linxiraos/pi-ai/providers/openai-shared";
|
|
29
29
|
import { transformMessages } from "@linxiraos/pi-ai/providers/transform-messages";
|
|
30
30
|
import type {
|
|
31
|
-
Api,
|
|
32
31
|
AssistantMessage,
|
|
33
32
|
CodexCompactionContext,
|
|
34
33
|
FetchImpl,
|
|
@@ -37,12 +36,14 @@ import type {
|
|
|
37
36
|
ProviderSessionState,
|
|
38
37
|
} from "@linxiraos/pi-ai/types";
|
|
39
38
|
import {
|
|
39
|
+
dropMalformedOpenAIResponsesToolCalls,
|
|
40
40
|
getOpenAIResponsesHistoryItems,
|
|
41
41
|
getOpenAIResponsesHistoryPayload,
|
|
42
42
|
normalizeResponsesToolCallId,
|
|
43
43
|
stripOpenAIResponsesOutputOnlyStatusesForReplay,
|
|
44
44
|
} from "@linxiraos/pi-ai/utils";
|
|
45
45
|
import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
|
|
46
|
+
import { isBedrockOpenAIUrl } from "@linxiraos/pi-catalog/hosts";
|
|
46
47
|
import {
|
|
47
48
|
applyCodexResidencyHeader,
|
|
48
49
|
CODEX_BASE_URL,
|
|
@@ -51,8 +52,11 @@ import {
|
|
|
51
52
|
OPENAI_HEADER_VALUES,
|
|
52
53
|
OPENAI_HEADERS,
|
|
53
54
|
} from "@linxiraos/pi-catalog/wire/codex";
|
|
54
|
-
import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
55
|
+
import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
55
56
|
import { Tokenizer } from "../tokenizer";
|
|
57
|
+
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
58
|
+
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
59
|
+
import { isOpenAiRemoteCompactionApi } from "./compaction-v2-streaming";
|
|
56
60
|
import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
|
|
57
61
|
|
|
58
62
|
export * from "./compaction-v2-streaming";
|
|
@@ -73,8 +77,6 @@ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
|
|
73
77
|
*/
|
|
74
78
|
export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
|
|
75
79
|
|
|
76
|
-
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
77
|
-
|
|
78
80
|
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
79
81
|
|
|
80
82
|
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
@@ -262,13 +264,6 @@ export function assertRemoteCompactionInputFits(trimmed: TrimRemoteCompactionInp
|
|
|
262
264
|
);
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
|
266
|
-
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
267
|
-
if (timeoutMs <= 0) return signal;
|
|
268
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
269
|
-
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
267
|
export type OpenAiRemoteCompactionItem = {
|
|
273
268
|
type: "compaction" | "compaction_summary";
|
|
274
269
|
encrypted_content?: string;
|
|
@@ -309,10 +304,6 @@ export interface RemoteCompactionResponse {
|
|
|
309
304
|
// OpenAI provider gating + endpoint resolution
|
|
310
305
|
// ============================================================================
|
|
311
306
|
|
|
312
|
-
export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
313
|
-
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
314
|
-
}
|
|
315
|
-
|
|
316
307
|
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
317
308
|
if (model.remoteCompaction?.enabled === false) return false;
|
|
318
309
|
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
@@ -323,6 +314,8 @@ export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
|
323
314
|
return (model.remoteCompaction?.endpoint?.trim().length ?? 0) > 0;
|
|
324
315
|
}
|
|
325
316
|
if (model.provider === "openai") return true;
|
|
317
|
+
// Amazon Bedrock's OpenAI routes serve `/responses/compact` without an opt-in.
|
|
318
|
+
if (compactionApi === "openai-responses" && isBedrockOpenAIUrl(model.baseUrl)) return true;
|
|
326
319
|
if (model.remoteCompaction?.enabled !== true) return false;
|
|
327
320
|
return isOpenAiRemoteCompactionApi(compactionApi);
|
|
328
321
|
}
|
|
@@ -353,25 +346,6 @@ function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: str
|
|
|
353
346
|
return appendAzureApiVersion(endpoint);
|
|
354
347
|
}
|
|
355
348
|
|
|
356
|
-
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
|
357
|
-
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
|
358
|
-
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
|
359
|
-
const resolvedBaseUrl =
|
|
360
|
-
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
|
361
|
-
if (!resolvedBaseUrl) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
return resolvedBaseUrl.replace(/\/+$/, "");
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function appendAzureApiVersion(endpoint: string): string {
|
|
370
|
-
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
|
371
|
-
const separator = endpoint.includes("?") ? "&" : "?";
|
|
372
|
-
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
349
|
function resolveOpenAiCompactModel(model: Model): string {
|
|
376
350
|
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
|
377
351
|
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
@@ -564,7 +538,10 @@ export function buildOpenAiNativeHistory(
|
|
|
564
538
|
}
|
|
565
539
|
}
|
|
566
540
|
}
|
|
567
|
-
const historyItems = adaptComputerHistoryForCompaction(
|
|
541
|
+
const historyItems = adaptComputerHistoryForCompaction(
|
|
542
|
+
dropMalformedOpenAIResponsesToolCalls(rawHistoryItems),
|
|
543
|
+
model.supportsComputerUse === true,
|
|
544
|
+
);
|
|
568
545
|
input.push(...historyItems);
|
|
569
546
|
addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
|
|
570
547
|
msgIndex++;
|
|
@@ -616,7 +593,7 @@ export function buildOpenAiNativeHistory(
|
|
|
616
593
|
}
|
|
617
594
|
}
|
|
618
595
|
const historyItems = adaptComputerHistoryForCompaction(
|
|
619
|
-
providerPayload.items,
|
|
596
|
+
dropMalformedOpenAIResponsesToolCalls(providerPayload.items),
|
|
620
597
|
model.supportsComputerUse === true,
|
|
621
598
|
);
|
|
622
599
|
if (providerPayload.dt) {
|
|
@@ -805,6 +782,10 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
805
782
|
codexCompaction?: CodexCompactionContext;
|
|
806
783
|
},
|
|
807
784
|
): Promise<OpenAiRemoteCompactionResponse> {
|
|
785
|
+
let fetchImpl: FetchImpl = opts?.fetch ?? fetch;
|
|
786
|
+
if (isBedrockOpenAIUrl(model.baseUrl)) {
|
|
787
|
+
({ model, apiKey, fetch: fetchImpl } = await prepareBedrockCompactionRequest(model, apiKey, opts?.fetch, signal));
|
|
788
|
+
}
|
|
808
789
|
const endpoint = resolveOpenAiCompactEndpoint(model);
|
|
809
790
|
const requestModel = resolveOpenAiCompactModel(model);
|
|
810
791
|
const trimmed = trimRemoteCompactionInputToContextWindow(
|
|
@@ -889,11 +870,11 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
889
870
|
}
|
|
890
871
|
}
|
|
891
872
|
|
|
892
|
-
const response = await (
|
|
873
|
+
const response = await fetchImpl(endpoint, {
|
|
893
874
|
method: "POST",
|
|
894
875
|
headers,
|
|
895
876
|
body: stringifyJson(request),
|
|
896
|
-
signal:
|
|
877
|
+
signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
897
878
|
});
|
|
898
879
|
|
|
899
880
|
if (!response.ok) {
|
|
@@ -945,10 +926,10 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
945
926
|
/**
|
|
946
927
|
* Generic remote-compaction POST. Two wire shapes are auto-selected by
|
|
947
928
|
* endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
|
|
948
|
-
* either a purpose-built
|
|
929
|
+
* either a purpose-built zeta summarizer (`{systemPrompt, prompt}` → `{summary}`)
|
|
949
930
|
* or any OpenAI-compatible chat-completions server (`/chat/completions`,
|
|
950
931
|
* `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
|
|
951
|
-
* issue #4630: without this, the
|
|
932
|
+
* issue #4630: without this, the zeta payload was rejected with
|
|
952
933
|
* HTTP 400 `"'messages' is required"`, compaction silently fell back to
|
|
953
934
|
* local summarization, and context grew unbounded.
|
|
954
935
|
*
|
|
@@ -992,7 +973,7 @@ export async function requestRemoteCompaction(
|
|
|
992
973
|
method: "POST",
|
|
993
974
|
headers,
|
|
994
975
|
body: stringifyJson(body),
|
|
995
|
-
signal:
|
|
976
|
+
signal: ptree.combineSignals(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
996
977
|
});
|
|
997
978
|
|
|
998
979
|
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,
|
|
@@ -61,6 +62,34 @@ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
|
61
62
|
export interface PruneResult {
|
|
62
63
|
prunedCount: number;
|
|
63
64
|
tokensSaved: number;
|
|
65
|
+
/**
|
|
66
|
+
* Restore every result this pass blanked. Pruning mutates entries in place,
|
|
67
|
+
* so a caller whose persistence of the pruned history fails calls this to keep
|
|
68
|
+
* memory matching what is durable.
|
|
69
|
+
*/
|
|
70
|
+
undo(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NOTHING_PRUNED: PruneResult = { prunedCount: 0, tokensSaved: 0, undo: () => {} };
|
|
74
|
+
|
|
75
|
+
/** Blank `message` to `notice`, returning the step that restores it. */
|
|
76
|
+
function blankToolResult(message: ToolResultMessage, notice: string, prunedAt: number): () => void {
|
|
77
|
+
const { content, prunedAt: previousPrunedAt } = message;
|
|
78
|
+
message.content = [{ type: "text", text: notice }];
|
|
79
|
+
message.prunedAt = prunedAt;
|
|
80
|
+
invalidateMessageCache(message as AgentMessage);
|
|
81
|
+
return () => {
|
|
82
|
+
message.content = content;
|
|
83
|
+
message.prunedAt = previousPrunedAt;
|
|
84
|
+
invalidateMessageCache(message as AgentMessage);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Combine per-message restore steps into one {@link PruneResult.undo}. */
|
|
89
|
+
function undoAll(steps: Array<() => void>): () => void {
|
|
90
|
+
return () => {
|
|
91
|
+
for (const step of steps) step();
|
|
92
|
+
};
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/** Exact placeholder written over a superseded tool result. */
|
|
@@ -122,13 +151,6 @@ function createPrunedNotice(tokens: number): string {
|
|
|
122
151
|
*/
|
|
123
152
|
export const MIN_PRUNE_TOKENS = 50;
|
|
124
153
|
|
|
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
154
|
function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
133
155
|
const noticeTokens = Math.ceil(notice.length / 4);
|
|
134
156
|
return Math.max(0, tokens - noticeTokens);
|
|
@@ -263,7 +285,7 @@ export function pruneSupersededToolResults(
|
|
|
263
285
|
candidates.push(...collectUselessResults(entries, tokenizer, toolCallsById, config.protectedTools, exclude));
|
|
264
286
|
candidates.sort((a, b) => a.index - b.index);
|
|
265
287
|
}
|
|
266
|
-
if (candidates.length === 0) return
|
|
288
|
+
if (candidates.length === 0) return NOTHING_PRUNED;
|
|
267
289
|
|
|
268
290
|
const now = config.now ?? Date.now();
|
|
269
291
|
let lastMessageTimestamp: number | undefined;
|
|
@@ -296,17 +318,15 @@ export function pruneSupersededToolResults(
|
|
|
296
318
|
candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
|
|
297
319
|
);
|
|
298
320
|
}
|
|
299
|
-
if (toPrune.length === 0) return
|
|
321
|
+
if (toPrune.length === 0) return NOTHING_PRUNED;
|
|
300
322
|
|
|
301
323
|
const prunedAt = Date.now();
|
|
302
324
|
let tokensSaved = 0;
|
|
303
|
-
|
|
304
|
-
candidate.message.content = [{ type: "text", text: candidate.notice }];
|
|
305
|
-
candidate.message.prunedAt = prunedAt;
|
|
306
|
-
invalidateMessageCache(candidate.message as AgentMessage);
|
|
325
|
+
const steps = toPrune.map(candidate => {
|
|
307
326
|
tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
|
|
308
|
-
|
|
309
|
-
|
|
327
|
+
return blankToolResult(candidate.message, candidate.notice, prunedAt);
|
|
328
|
+
});
|
|
329
|
+
return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
|
|
310
330
|
}
|
|
311
331
|
|
|
312
332
|
export function pruneToolOutputs(
|
|
@@ -316,7 +336,6 @@ export function pruneToolOutputs(
|
|
|
316
336
|
): PruneResult {
|
|
317
337
|
let accumulatedTokens = 0;
|
|
318
338
|
let tokensSaved = 0;
|
|
319
|
-
let prunedCount = 0;
|
|
320
339
|
|
|
321
340
|
const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
|
|
322
341
|
const toolCallsById = collectToolCallsById(entries);
|
|
@@ -400,24 +419,20 @@ export function pruneToolOutputs(
|
|
|
400
419
|
}
|
|
401
420
|
|
|
402
421
|
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
403
|
-
return
|
|
422
|
+
return NOTHING_PRUNED;
|
|
404
423
|
}
|
|
405
424
|
|
|
406
425
|
const prunedAt = Date.now();
|
|
407
|
-
|
|
408
|
-
const message = candidate.entry.message as ToolResultMessage;
|
|
426
|
+
const steps = candidates.map(candidate => {
|
|
409
427
|
const notice = candidate.superseded
|
|
410
428
|
? SUPERSEDED_NOTICE
|
|
411
429
|
: candidate.useless
|
|
412
430
|
? USELESS_NOTICE
|
|
413
431
|
: createPrunedNotice(candidate.tokens);
|
|
414
|
-
message
|
|
415
|
-
|
|
416
|
-
invalidateMessageCache(message as AgentMessage);
|
|
417
|
-
prunedCount++;
|
|
418
|
-
}
|
|
432
|
+
return blankToolResult(candidate.entry.message as ToolResultMessage, notice, prunedAt);
|
|
433
|
+
});
|
|
419
434
|
|
|
420
|
-
return { prunedCount, tokensSaved };
|
|
435
|
+
return { prunedCount: candidates.length, tokensSaved, undo: undoAll(steps) };
|
|
421
436
|
}
|
|
422
437
|
|
|
423
438
|
/**
|
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 "@linxiraos/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.
|
package/src/proxy.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setStreamingPartialJson,
|
|
22
22
|
} from "@linxiraos/pi-ai/utils/block-symbols";
|
|
23
23
|
import { parseStreamingJson, parseStreamingJsonThrottled, readSseJson } from "@linxiraos/pi-utils";
|
|
24
|
+
import { parseToolCallArguments } from "@linxiraos/pi-ai/utils/tool-call-arguments";
|
|
24
25
|
|
|
25
26
|
// Event stream adapter for proxy SSE events
|
|
26
27
|
export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
|
@@ -378,7 +379,7 @@ function processProxyEvent(
|
|
|
378
379
|
// throttle may have skipped trailing deltas, so the last
|
|
379
380
|
// parsed arguments can lag the accumulated buffer.
|
|
380
381
|
const acc = partialJsonByIndex.get(proxyEvent.contentIndex);
|
|
381
|
-
if (acc !== undefined && acc.length > 0) content.arguments =
|
|
382
|
+
if (acc !== undefined && acc.length > 0) content.arguments = parseToolCallArguments(acc) || {};
|
|
382
383
|
partialJsonByIndex.delete(proxyEvent.contentIndex);
|
|
383
384
|
parsedLenByIndex.delete(proxyEvent.contentIndex);
|
|
384
385
|
clearStreamingPartialJson(content);
|
|
@@ -427,6 +428,6 @@ function finalizeBufferedArguments(partial: AssistantMessage, partialJsonByIndex
|
|
|
427
428
|
for (const [index, acc] of partialJsonByIndex) {
|
|
428
429
|
if (acc.length === 0) continue;
|
|
429
430
|
const content = partial.content[index];
|
|
430
|
-
if (content?.type === "toolCall") content.arguments =
|
|
431
|
+
if (content?.type === "toolCall") content.arguments = parseToolCallArguments(acc) || {};
|
|
431
432
|
}
|
|
432
433
|
}
|
|
@@ -714,12 +714,12 @@ export class SpeculativeOperationCoordinator {
|
|
|
714
714
|
try {
|
|
715
715
|
validatedArgs = validateToolArguments(tool, toolCall);
|
|
716
716
|
} catch {
|
|
717
|
-
|
|
717
|
+
// Lenience covers schema mismatches; a parse failure has no args to hand over.
|
|
718
|
+
if (!tool.lenientArgValidation || "__parseError" in toolCall.arguments) {
|
|
718
719
|
this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
|
|
719
720
|
return undefined;
|
|
720
721
|
}
|
|
721
|
-
validatedArgs = { ...
|
|
722
|
-
delete validatedArgs.__parseError;
|
|
722
|
+
validatedArgs = { ...toolCall.arguments };
|
|
723
723
|
delete validatedArgs.__rawJson;
|
|
724
724
|
}
|
|
725
725
|
let executionArgs: Record<string, unknown>;
|
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/types.ts
CHANGED
|
@@ -906,6 +906,13 @@ export interface AfterToolCallResult {
|
|
|
906
906
|
isError?: boolean;
|
|
907
907
|
/** If provided, replaces the contextually-useless flag carried with the tool result. */
|
|
908
908
|
useless?: boolean;
|
|
909
|
+
/**
|
|
910
|
+
* Trusted post-tool instructions for the next provider request. Delivered
|
|
911
|
+
* outside the tool result, after all calls in the batch settle. Unlike
|
|
912
|
+
* `BeforeToolCallResult.additionalContext`, this is retained for error
|
|
913
|
+
* results because the callback receives the finalized outcome.
|
|
914
|
+
*/
|
|
915
|
+
additionalContext?: string;
|
|
909
916
|
}
|
|
910
917
|
|
|
911
918
|
/** Context passed to `beforeToolCall`. */
|
|
@@ -1136,7 +1143,7 @@ export interface AgentTool<
|
|
|
1136
1143
|
* before ordinary dispatch commits its result.
|
|
1137
1144
|
*/
|
|
1138
1145
|
speculation?: ToolSpeculationPolicy;
|
|
1139
|
-
/** If true,
|
|
1146
|
+
/** If true, schema validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. Malformed argument JSON still returns the parse error. */
|
|
1140
1147
|
lenientArgValidation?: boolean;
|
|
1141
1148
|
/**
|
|
1142
1149
|
* Whether the agent loop may abort this tool mid-execution — or skip it
|