@linxiraos/pi-agent-core 1.1.20 → 1.1.22

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +241 -2
  2. package/README.md +4 -3
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/dist/types/agent-loop.d.ts +14 -1
  5. package/dist/types/agent.d.ts +36 -0
  6. package/dist/types/compaction/anthropic.d.ts +22 -52
  7. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  8. package/dist/types/compaction/compaction.d.ts +4 -2
  9. package/dist/types/compaction/messages.d.ts +9 -0
  10. package/dist/types/compaction/openai.d.ts +11 -0
  11. package/dist/types/compaction/pruning.d.ts +9 -0
  12. package/dist/types/compaction/transcript-tokens.d.ts +25 -2
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/live-steering.d.ts +34 -0
  15. package/dist/types/output-budget.d.ts +54 -0
  16. package/dist/types/sent-tool-definitions.d.ts +17 -0
  17. package/dist/types/speculative-execution.d.ts +2 -1
  18. package/dist/types/telemetry.d.ts +103 -60
  19. package/dist/types/tool-context.d.ts +32 -0
  20. package/dist/types/types.d.ts +105 -7
  21. package/package.json +10 -10
  22. package/src/agent-loop.ts +386 -90
  23. package/src/agent.ts +175 -26
  24. package/src/compaction/anthropic.ts +62 -105
  25. package/src/compaction/branch-summarization.ts +1 -1
  26. package/src/compaction/compaction-v2-streaming.ts +13 -4
  27. package/src/compaction/compaction.ts +63 -51
  28. package/src/compaction/messages.ts +12 -2
  29. package/src/compaction/openai.ts +37 -9
  30. package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
  31. package/src/compaction/pruning.ts +1 -1
  32. package/src/compaction/transcript-tokens.ts +64 -3
  33. package/src/index.ts +6 -0
  34. package/src/live-steering.ts +89 -0
  35. package/src/output-budget.ts +142 -0
  36. package/src/sent-tool-definitions.ts +40 -0
  37. package/src/speculative-execution.ts +12 -0
  38. package/src/telemetry.ts +240 -117
  39. package/src/tokenizer.ts +45 -6
  40. package/src/tool-context.ts +55 -0
  41. 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(