@linxiraos/pi-agent-core 1.1.20 → 1.1.23
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 +243 -2
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.txt +3 -40
- package/dist/types/agent-loop.d.ts +14 -1
- package/dist/types/agent.d.ts +36 -0
- package/dist/types/compaction/anthropic.d.ts +22 -52
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +4 -2
- package/dist/types/compaction/messages.d.ts +9 -0
- package/dist/types/compaction/openai.d.ts +11 -0
- package/dist/types/compaction/pruning.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +25 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +54 -0
- package/dist/types/sent-tool-definitions.d.ts +17 -0
- package/dist/types/speculative-execution.d.ts +2 -1
- package/dist/types/telemetry.d.ts +103 -60
- package/dist/types/tool-context.d.ts +32 -0
- package/dist/types/types.d.ts +105 -7
- package/package.json +10 -10
- package/src/agent-loop.ts +386 -90
- package/src/agent.ts +175 -26
- package/src/compaction/anthropic.ts +62 -105
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +13 -4
- package/src/compaction/compaction.ts +63 -51
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/openai.ts +37 -9
- package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +64 -3
- package/src/index.ts +6 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +142 -0
- package/src/sent-tool-definitions.ts +40 -0
- package/src/speculative-execution.ts +12 -0
- package/src/telemetry.ts +240 -117
- package/src/tokenizer.ts +45 -6
- package/src/tool-context.ts +55 -0
- package/src/types.ts +109 -7
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent side of provider live steering ({@link LiveSteering}).
|
|
3
|
+
*
|
|
4
|
+
* A provider that can put user input into the response it is streaming (OpenAI
|
|
5
|
+
* Responses `response.steer`) pulls queued steering through a
|
|
6
|
+
* {@link LiveSteeringChannel}. The loop records what the provider accepted right
|
|
7
|
+
* after that response, so the transcript matches what the model saw; anything
|
|
8
|
+
* it declined is injected at the next boundary like ordinary steering.
|
|
9
|
+
*/
|
|
10
|
+
import type { LiveSteerClaim, LiveSteering, UserMessage } from "@linxiraos/pi-ai";
|
|
11
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
12
|
+
import type { AgentMessage } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Steering-queue access for one provider call, supplied by the agent loop. */
|
|
15
|
+
export interface LiveSteeringQueue {
|
|
16
|
+
/** Resolves once steering is queued or `signal` aborts; never consumes. */
|
|
17
|
+
wait(signal: AbortSignal): Promise<void>;
|
|
18
|
+
/** Dequeues the next steering batch. */
|
|
19
|
+
take(signal: AbortSignal): Promise<AgentMessage[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Provider view of `messages` appended to the in-flight call's context, or
|
|
22
|
+
* `undefined` when that view is not purely user messages.
|
|
23
|
+
*/
|
|
24
|
+
toProvider(messages: AgentMessage[], signal: AbortSignal): Promise<UserMessage[] | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provider call's {@link LiveSteering} source. */
|
|
28
|
+
export class LiveSteeringChannel implements LiveSteering {
|
|
29
|
+
/** Steering the provider delivered into the in-flight response, in queue order. */
|
|
30
|
+
readonly accepted: AgentMessage[] = [];
|
|
31
|
+
/** Steering taken from the queue but not delivered; always queued after {@link accepted}. */
|
|
32
|
+
readonly deferred: AgentMessage[] = [];
|
|
33
|
+
readonly #queue: LiveSteeringQueue;
|
|
34
|
+
|
|
35
|
+
constructor(queue: LiveSteeringQueue) {
|
|
36
|
+
this.#queue = queue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
wait(signal: AbortSignal): Promise<void> {
|
|
40
|
+
// Once input is deferred, later input must follow it at the boundary;
|
|
41
|
+
// delivering it live would reorder the user's messages.
|
|
42
|
+
if (this.deferred.length === 0) return this.#queue.wait(signal);
|
|
43
|
+
if (signal.aborted) return Promise.resolve();
|
|
44
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
45
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async claim(signal: AbortSignal): Promise<LiveSteerClaim | undefined> {
|
|
50
|
+
if (this.deferred.length > 0 || signal.aborted) return undefined;
|
|
51
|
+
let messages: AgentMessage[];
|
|
52
|
+
try {
|
|
53
|
+
messages = await this.#queue.take(signal);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// The queue restores what it could not hand over.
|
|
56
|
+
logger.debug("Live steering dequeue failed", {
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
});
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (messages.length === 0) return undefined;
|
|
62
|
+
let providerMessages: UserMessage[] | undefined;
|
|
63
|
+
try {
|
|
64
|
+
providerMessages = await this.#queue.toProvider(messages, signal);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.debug("Live steering conversion failed", {
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!providerMessages) {
|
|
71
|
+
this.deferred.push(...messages);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
let settled = false;
|
|
75
|
+
return {
|
|
76
|
+
messages: providerMessages,
|
|
77
|
+
accept: () => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
this.accepted.push(...messages);
|
|
81
|
+
},
|
|
82
|
+
reject: () => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
this.deferred.push(...messages);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Context, Model, Tool } from "@linxiraos/pi-ai";
|
|
2
|
+
import { stopsOutputAtContextWindow } from "@linxiraos/pi-catalog/compat/output-limits";
|
|
3
|
+
import { stringifyJson } from "@linxiraos/pi-utils";
|
|
4
|
+
import { findRequestUsageAnchor } from "./compaction/transcript-tokens";
|
|
5
|
+
import type { Tokenizer } from "./tokenizer";
|
|
6
|
+
|
|
7
|
+
/** Smallest output cap {@link fitOutputTokensToContextWindow} will request. */
|
|
8
|
+
export const MIN_FITTED_OUTPUT_TOKENS = 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Local counts are padded by 1/this before sizing the output cap: the
|
|
12
|
+
* provider's tokenizer can disagree with ours by a few percent, and
|
|
13
|
+
* undercounting reproduces the overflow this guards against. This is a
|
|
14
|
+
* tokenizer-error margin on locally counted text only (provider-reported
|
|
15
|
+
* usage is exact), not a context reserve; compaction's own reserve
|
|
16
|
+
* (`resolveBudgetReserveTokens`) still decides when to compact.
|
|
17
|
+
*/
|
|
18
|
+
const PROMPT_ESTIMATE_MARGIN_DIVISOR = 10;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute headway subtracted from the remaining room (unconditionally, on anchored and
|
|
22
|
+
* fully-local counts alike). Proportional padding covers
|
|
23
|
+
* tokenizer drift that scales with the prompt, but a host can still count a few tokens more
|
|
24
|
+
* than any local estimate can see (chat-template framing, reasoning wrappers). Measured
|
|
25
|
+
* against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were rejected
|
|
26
|
+
* with 400s. 64 tokens covers the observed drift with margin to spare; the
|
|
27
|
+
* {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
|
|
28
|
+
*/
|
|
29
|
+
export const OUTPUT_FIT_HEADWAY_TOKENS = 64;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Output cap for a request, so prompt plus output stays inside the model's
|
|
33
|
+
* context window.
|
|
34
|
+
*
|
|
35
|
+
* Chat Completions-style providers (DeepSeek, OpenAI, vLLM, ...) reject a
|
|
36
|
+
* request whose prompt tokens plus `max_tokens` exceed the window. Every
|
|
37
|
+
* request asks for `model.maxTokens` of output by default, so without this a
|
|
38
|
+
* large model output cap (DeepSeek V4: ~384k of a ~1M window) makes every
|
|
39
|
+
* request fail once the prompt passes window minus output cap, long before
|
|
40
|
+
* compaction triggers, and side turns (`/btw`, recaps) have no overflow
|
|
41
|
+
* recovery at all.
|
|
42
|
+
*
|
|
43
|
+
* The prompt size is the provider's own report from the newest trustworthy
|
|
44
|
+
* assistant turn (see {@link findRequestUsageAnchor}) plus a local count of
|
|
45
|
+
* only the messages appended after it; the whole context is counted locally
|
|
46
|
+
* only when no turn can anchor (fresh or freshly rewritten context).
|
|
47
|
+
*
|
|
48
|
+
* Returns `maxTokens` unchanged when the requested cap already fits, the
|
|
49
|
+
* model declares no window, the host ends generation at the window itself
|
|
50
|
+
* instead of rejecting the request (`stops-output-at-context-window`, e.g.
|
|
51
|
+
* Claude 4.5+ on the Claude API), or nothing would be requested (including an
|
|
52
|
+
* OpenRouter-hosted model with no caller cap: the transport omits the catalog
|
|
53
|
+
* default there so each upstream self-caps, and a fitted value would turn into
|
|
54
|
+
* an explicit cap that filters upstreams). Otherwise returns
|
|
55
|
+
* the remaining room minus {@link OUTPUT_FIT_HEADWAY_TOKENS} (never below
|
|
56
|
+
* {@link MIN_FITTED_OUTPUT_TOKENS}); a
|
|
57
|
+
* prompt that fills the whole window still overflows and is left to the
|
|
58
|
+
* caller's compaction. Near a full window the floor means a turn can stop on
|
|
59
|
+
* `length` instead of failing with a 400.
|
|
60
|
+
*
|
|
61
|
+
* Lives here, not next to the default in pi-ai's `mapOptionsForApi`, because
|
|
62
|
+
* pi-ai has no tokenizer; callers apply it in their `streamFn` (coding-agent
|
|
63
|
+
* does so in its shared settings-aware wrapper).
|
|
64
|
+
*
|
|
65
|
+
* Not fixed: Anthropic budget-thinking transports raise `max_tokens` back to
|
|
66
|
+
* at least the thinking budget plus a fallback buffer downstream
|
|
67
|
+
* (`ensureMaxTokensForThinking`), so a fitted cap below that is overridden
|
|
68
|
+
* and the request can still exceed the window as before.
|
|
69
|
+
*/
|
|
70
|
+
export function fitOutputTokensToContextWindow(
|
|
71
|
+
model: Model,
|
|
72
|
+
context: Context,
|
|
73
|
+
maxTokens: number | undefined,
|
|
74
|
+
tokenizer: Tokenizer,
|
|
75
|
+
): number | undefined {
|
|
76
|
+
if (maxTokens === undefined && omitsDefaultOutputCap(model.compat)) return undefined;
|
|
77
|
+
const requested = maxTokens ?? model.maxTokens;
|
|
78
|
+
const contextWindow = model.contextWindow;
|
|
79
|
+
if (!requested || !contextWindow || contextWindow <= 0) return maxTokens;
|
|
80
|
+
if (stopsOutputAtContextWindow(model)) return maxTokens;
|
|
81
|
+
|
|
82
|
+
const room = contextWindow - countPromptTokens(context, tokenizer) - OUTPUT_FIT_HEADWAY_TOKENS;
|
|
83
|
+
if (room >= requested) return maxTokens;
|
|
84
|
+
return Math.max(MIN_FITTED_OUTPUT_TOKENS, room);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** OpenRouter hosts drop the catalog default cap unless the endpoint always needs one. */
|
|
88
|
+
function omitsDefaultOutputCap(compat: Model["compat"] | undefined): boolean {
|
|
89
|
+
return (
|
|
90
|
+
compat !== undefined && "isOpenRouterHost" in compat && compat.isOpenRouterHost && !compat.alwaysSendMaxTokens
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Framing tokens (system prompt, tool definitions) memoized per array: these
|
|
96
|
+
* are stable identities for the life of a turn, so side turns and repeat
|
|
97
|
+
* requests do not re-stringify and re-tokenize them. Length is part of the key
|
|
98
|
+
* to catch in-place growth.
|
|
99
|
+
*/
|
|
100
|
+
const framingCounts = new WeakMap<readonly unknown[], { tokenizer: Tokenizer; length: number; tokens: number }>();
|
|
101
|
+
|
|
102
|
+
function countFraming(items: readonly unknown[] | undefined, tokenizer: Tokenizer, fragments: () => string[]): number {
|
|
103
|
+
if (!items || items.length === 0) return 0;
|
|
104
|
+
const cached = framingCounts.get(items);
|
|
105
|
+
if (cached && cached.tokenizer === tokenizer && cached.length === items.length) return cached.tokens;
|
|
106
|
+
const tokens = tokenizer.countTokens(fragments());
|
|
107
|
+
framingCounts.set(items, { tokenizer, length: items.length, tokens });
|
|
108
|
+
return tokens;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toolFragments(tools: readonly Tool[]): string[] {
|
|
112
|
+
const fragments: string[] = [];
|
|
113
|
+
for (const tool of tools) fragments.push(tool.name, tool.description, stringifyJson(tool.parameters) ?? "");
|
|
114
|
+
return fragments;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function withMargin(localTokens: number): number {
|
|
118
|
+
return localTokens + Math.ceil(localTokens / PROMPT_ESTIMATE_MARGIN_DIVISOR);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Provider-anchored prompt size; falls back to a full local count when nothing anchors. */
|
|
122
|
+
function countPromptTokens(context: Context, tokenizer: Tokenizer): number {
|
|
123
|
+
const { messages } = context;
|
|
124
|
+
const anchor = findRequestUsageAnchor(messages);
|
|
125
|
+
if (!anchor) return withMargin(countContextTokens(context, tokenizer));
|
|
126
|
+
let tail = 0;
|
|
127
|
+
for (let index = anchor.index + 1; index < messages.length; index++) {
|
|
128
|
+
tail += tokenizer.countMessage(messages[index]);
|
|
129
|
+
}
|
|
130
|
+
return anchor.tokens + withMargin(tail);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function countContextTokens(context: Context, tokenizer: Tokenizer): number {
|
|
134
|
+
const { systemPrompt, tools, inactiveTools } = context;
|
|
135
|
+
return (
|
|
136
|
+
countFraming(systemPrompt, tokenizer, () => [...(systemPrompt ?? [])]) +
|
|
137
|
+
countFraming(tools, tokenizer, () => toolFragments(tools ?? [])) +
|
|
138
|
+
// Anthropic replays retired tool definitions, so they are prompt too.
|
|
139
|
+
countFraming(inactiveTools, tokenizer, () => toolFragments(inactiveTools ?? [])) +
|
|
140
|
+
tokenizer.countMessages(context.messages)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Message, Tool } from "@linxiraos/pi-ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Last wire definition this Agent sent for each tool name, so a provider that keeps
|
|
5
|
+
* withdrawn tools declared (Anthropic `tool_removal`) can re-declare them byte-identically.
|
|
6
|
+
* Used by prepareProviderCall and Agent.buildSideRequestContext.
|
|
7
|
+
*/
|
|
8
|
+
export class SentToolDefinitions {
|
|
9
|
+
#byName = new Map<string, Tool>();
|
|
10
|
+
|
|
11
|
+
/** Remember the definitions a request is about to send. */
|
|
12
|
+
record(tools: readonly Tool[]): void {
|
|
13
|
+
for (const tool of tools) this.#byName.set(tool.name, tool);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Definitions for names the latest `requestControls.tools.declared` in `messages` holds
|
|
18
|
+
* that are not in `active`; undefined when none. Names never sent by this Agent are
|
|
19
|
+
* skipped: the provider drops them from the declaration.
|
|
20
|
+
*/
|
|
21
|
+
inactiveFor(messages: readonly Message[], active: readonly Tool[]): Tool[] | undefined {
|
|
22
|
+
let declared: readonly string[] | undefined;
|
|
23
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
24
|
+
const message = messages[index];
|
|
25
|
+
if (message?.role === "assistant" && message.requestControls?.tools) {
|
|
26
|
+
declared = message.requestControls.tools.declared;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!declared) return undefined;
|
|
31
|
+
const activeNames = new Set(active.map(tool => tool.name));
|
|
32
|
+
const inactive: Tool[] = [];
|
|
33
|
+
for (const name of declared) {
|
|
34
|
+
if (activeNames.has(name)) continue;
|
|
35
|
+
const tool = this.#byName.get(name);
|
|
36
|
+
if (tool) inactive.push(tool);
|
|
37
|
+
}
|
|
38
|
+
return inactive.length > 0 ? inactive : undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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(
|