@linxiraos/pi-agent-core 1.0.10 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +2 -1292
  2. package/LICENSE +23 -0
  3. package/package.json +77 -78
  4. package/src/agent-loop.ts +17 -2
  5. package/src/agent.ts +25 -4
  6. package/src/append-only-context.ts +28 -2
  7. package/src/compaction/branch-summarization.ts +15 -8
  8. package/src/compaction/compaction-v2-streaming.ts +2 -0
  9. package/src/compaction/compaction.ts +43 -152
  10. package/src/compaction/errors.ts +2 -2
  11. package/src/compaction/index.ts +1 -0
  12. package/src/compaction/message-cache.ts +33 -34
  13. package/src/compaction/messages.ts +37 -6
  14. package/src/compaction/openai.ts +48 -18
  15. package/src/compaction/prompts/handoff-document.md +2 -0
  16. package/src/compaction/prompts/handoff-summary-context.md +9 -0
  17. package/src/compaction/pruning.ts +25 -13
  18. package/src/compaction/shake.ts +18 -16
  19. package/src/compaction/transcript-tokens.ts +111 -0
  20. package/src/tokenizer.ts +273 -18
  21. package/src/types.ts +1 -1
  22. package/dist/types/agent-loop.d.ts +0 -137
  23. package/dist/types/agent.d.ts +0 -465
  24. package/dist/types/append-only-context.d.ts +0 -131
  25. package/dist/types/compaction/branch-summarization.d.ts +0 -101
  26. package/dist/types/compaction/compaction-v2-streaming.d.ts +0 -85
  27. package/dist/types/compaction/compaction.d.ts +0 -334
  28. package/dist/types/compaction/entries.d.ts +0 -120
  29. package/dist/types/compaction/errors.d.ts +0 -38
  30. package/dist/types/compaction/index.d.ts +0 -13
  31. package/dist/types/compaction/message-cache.d.ts +0 -23
  32. package/dist/types/compaction/messages.d.ts +0 -79
  33. package/dist/types/compaction/openai.d.ts +0 -117
  34. package/dist/types/compaction/pruning.d.ts +0 -105
  35. package/dist/types/compaction/shake.d.ts +0 -99
  36. package/dist/types/compaction/tool-protection.d.ts +0 -19
  37. package/dist/types/compaction/utils.d.ts +0 -63
  38. package/dist/types/compaction.d.ts +0 -1
  39. package/dist/types/index.d.ts +0 -13
  40. package/dist/types/pause.d.ts +0 -46
  41. package/dist/types/proxy.d.ts +0 -91
  42. package/dist/types/replay-policy.d.ts +0 -5
  43. package/dist/types/run-collector.d.ts +0 -196
  44. package/dist/types/telemetry.d.ts +0 -625
  45. package/dist/types/thinking.d.ts +0 -18
  46. package/dist/types/tokenizer.d.ts +0 -2
  47. package/dist/types/types.d.ts +0 -786
  48. package/dist/types/utils/yield.d.ts +0 -71
@@ -10,6 +10,8 @@ Capture exact technical state, not abstractions.
10
10
  - Test results, observed failures
11
11
  - Decisions made
12
12
  - Partial work affecting the next step
13
+ Register: address the successor directly in the imperative ("Fix X", "Run Y") — never first person ("I need to…", "my attempt…").
14
+ The handoff mechanism is invisible to the document: NEVER list writing, generating, or delivering a handoff/summary/context document as progress or a next step. Progress and Next Steps cover the user's task only.
13
15
  </instruction>
14
16
 
15
17
  <output>
@@ -0,0 +1,9 @@
1
+ Context replaced. The <handoff> below is a handoff document a prior instance of you wrote from the full conversation. It is your own working memory, not user input.
2
+ - First person inside it refers to you (the prior instance).
3
+ - "Next Steps" is your own resumed plan; re-check it against the latest user message before acting.
4
+ - The handoff already exists and is complete: NEVER write another handoff document unless the user explicitly asks.
5
+ MUST build on prior work; NEVER duplicate prior work.
6
+
7
+ <handoff>
8
+ {{summary}}
9
+ </handoff>
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { ToolResultMessage } from "@linxiraos/pi-ai";
6
+ import type { Tokenizer } from "../tokenizer";
6
7
  import type { AgentMessage, AgentToolCall } from "../types";
7
- import { estimateTokens } from "./compaction";
8
8
  import type { SessionEntry, SessionMessageEntry } from "./entries";
9
9
  import { invalidateMessageCache } from "./message-cache";
10
10
  import {
@@ -140,13 +140,13 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
140
140
  * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
141
141
  * mutations inside the cheap-to-recache tail.
142
142
  */
143
- function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] {
143
+ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
144
144
  const suffix = new Array<number>(entries.length);
145
145
  let accumulated = 0;
146
146
  for (let i = entries.length - 1; i >= 0; i--) {
147
147
  suffix[i] = accumulated;
148
148
  const entry = entries[i];
149
- if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
149
+ if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
150
150
  }
151
151
  return suffix;
152
152
  }
@@ -181,6 +181,7 @@ interface SupersedeCandidate {
181
181
  */
182
182
  function collectSupersededResults(
183
183
  entries: readonly SessionEntry[],
184
+ tokenizer: Tokenizer,
184
185
  toolCallsById: ReadonlyMap<string, AgentToolCall>,
185
186
  supersedeKey: SupersedeKeyFn,
186
187
  protectedTools: readonly ProtectedToolMatcher[],
@@ -204,7 +205,7 @@ function collectSupersededResults(
204
205
  entry: entry as SessionMessageEntry,
205
206
  message,
206
207
  index: i,
207
- tokens: estimateTokens(message as AgentMessage),
208
+ tokens: tokenizer.countMessage(message as AgentMessage),
208
209
  notice: SUPERSEDED_NOTICE,
209
210
  });
210
211
  }
@@ -219,6 +220,7 @@ function collectSupersededResults(
219
220
  */
220
221
  function collectUselessResults(
221
222
  entries: readonly SessionEntry[],
223
+ tokenizer: Tokenizer,
222
224
  toolCallsById: ReadonlyMap<string, AgentToolCall>,
223
225
  protectedTools: readonly ProtectedToolMatcher[],
224
226
  exclude: ReadonlySet<ToolResultMessage>,
@@ -230,7 +232,7 @@ function collectUselessResults(
230
232
  if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
231
233
  if (exclude.has(message)) continue;
232
234
  if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
233
- const tokens = estimateTokens(message as AgentMessage);
235
+ const tokens = tokenizer.countMessage(message as AgentMessage);
234
236
  if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
235
237
  candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
236
238
  }
@@ -246,14 +248,18 @@ function collectUselessResults(
246
248
  * the provider cache is cold anyway (then all still-sent candidates flush).
247
249
  * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
248
250
  */
249
- export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
251
+ export function pruneSupersededToolResults(
252
+ entries: SessionEntry[],
253
+ tokenizer: Tokenizer,
254
+ config: SupersedePruneConfig,
255
+ ): PruneResult {
250
256
  const toolCallsById = collectToolCallsById(entries);
251
257
  const candidates = config.supersedeKey
252
- ? collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools)
258
+ ? collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools)
253
259
  : [];
254
260
  if (config.pruneUseless) {
255
261
  const exclude = new Set(candidates.map(candidate => candidate.message));
256
- candidates.push(...collectUselessResults(entries, toolCallsById, config.protectedTools, exclude));
262
+ candidates.push(...collectUselessResults(entries, tokenizer, toolCallsById, config.protectedTools, exclude));
257
263
  candidates.sort((a, b) => a.index - b.index);
258
264
  }
259
265
  if (candidates.length === 0) return { prunedCount: 0, tokensSaved: 0 };
@@ -284,7 +290,7 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
284
290
  // Mutating a candidate re-writes its suffix in the warm cache, so prune only
285
291
  // when that suffix is small (cheap-to-recache tail) and the candidate sits
286
292
  // at/after the compaction boundary.
287
- const suffixTokens = computeMessageSuffixTokens(entries);
293
+ const suffixTokens = computeMessageSuffixTokens(entries, tokenizer);
288
294
  toPrune = candidates.filter(
289
295
  candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
290
296
  );
@@ -302,7 +308,11 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
302
308
  return { prunedCount: toPrune.length, tokensSaved };
303
309
  }
304
310
 
305
- export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
311
+ export function pruneToolOutputs(
312
+ entries: SessionEntry[],
313
+ tokenizer: Tokenizer,
314
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
315
+ ): PruneResult {
306
316
  let accumulatedTokens = 0;
307
317
  let tokensSaved = 0;
308
318
  let prunedCount = 0;
@@ -311,7 +321,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
311
321
  const toolCallsById = collectToolCallsById(entries);
312
322
  const supersededMessages = config.supersedeKey
313
323
  ? new Set(
314
- collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools).map(
324
+ collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools).map(
315
325
  candidate => candidate.message,
316
326
  ),
317
327
  )
@@ -321,6 +331,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
321
331
  ? new Set(
322
332
  collectUselessResults(
323
333
  entries,
334
+ tokenizer,
324
335
  toolCallsById,
325
336
  config.protectedTools,
326
337
  supersededMessages ?? new Set(),
@@ -331,14 +342,15 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
331
342
  const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
332
343
  const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
333
344
  // All-message suffix per index, only when the cache guard is armed.
334
- const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries);
345
+ const messageSuffix =
346
+ cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries, tokenizer);
335
347
 
336
348
  for (let i = entries.length - 1; i >= 0; i--) {
337
349
  const entry = entries[i];
338
350
  const message = getToolResultMessage(entry);
339
351
  if (!message) continue;
340
352
 
341
- const tokens = estimateTokens(message as AgentMessage);
353
+ const tokens = tokenizer.countMessage(message as AgentMessage);
342
354
  const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
343
355
 
344
356
  if (message.prunedAt !== undefined) {
@@ -11,9 +11,8 @@
11
11
  */
12
12
 
13
13
  import type { TextContent, ToolResultMessage } from "@linxiraos/pi-ai";
14
- import { countTokens } from "../tokenizer";
14
+ import type { Tokenizer } from "../tokenizer";
15
15
  import type { AgentMessage } from "../types";
16
- import { estimateTokens } from "./compaction";
17
16
  import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./entries";
18
17
  import { invalidateMessageCache } from "./message-cache";
19
18
  import {
@@ -123,15 +122,15 @@ function toolResultText(message: ToolResultMessage): string {
123
122
  }
124
123
 
125
124
  /** Estimate the token contribution of an entry for the protect-recent window. */
126
- function entryTokens(entry: SessionEntry): number {
125
+ function entryTokens(entry: SessionEntry, tokenizer: Tokenizer): number {
127
126
  if (entry.type === "message") {
128
- return estimateTokens(entry.message);
127
+ return tokenizer.countMessage(entry.message);
129
128
  }
130
129
  if (entry.type === "custom_message") {
131
130
  const content = entry.content;
132
- if (typeof content === "string") return content.length === 0 ? 0 : countTokens(content);
131
+ if (typeof content === "string") return content.length === 0 ? 0 : tokenizer.countTokens(content);
133
132
  const fragments = content.filter((block): block is TextContent => block.type === "text").map(block => block.text);
134
- return fragments.length === 0 ? 0 : countTokens(fragments);
133
+ return fragments.length === 0 ? 0 : tokenizer.countTokens(fragments);
135
134
  }
136
135
  return 0;
137
136
  }
@@ -222,6 +221,7 @@ function pushBlockRegions(
222
221
  entry: SessionMessageEntry | CustomMessageEntry,
223
222
  blockIndex: number,
224
223
  text: string,
224
+ tokenizer: Tokenizer,
225
225
  config: ShakeConfig,
226
226
  label: string,
227
227
  out: ShakeRegion[],
@@ -229,7 +229,7 @@ function pushBlockRegions(
229
229
  for (const range of scanTextForBlockRanges(text)) {
230
230
  const slice = text.slice(range.start, range.end);
231
231
  if (slice.length === 0) continue;
232
- const tokens = countTokens(slice);
232
+ const tokens = tokenizer.countTokens(slice);
233
233
  if (tokens < config.fenceMinTokens) continue;
234
234
  out.push({
235
235
  kind: "block",
@@ -246,6 +246,7 @@ function pushBlockRegions(
246
246
 
247
247
  function collectBlockRegions(
248
248
  entry: SessionMessageEntry | CustomMessageEntry,
249
+ tokenizer: Tokenizer,
249
250
  config: ShakeConfig,
250
251
  out: ShakeRegion[],
251
252
  ): void {
@@ -254,34 +255,35 @@ function collectBlockRegions(
254
255
  if (message.role === "assistant") {
255
256
  for (let bi = 0; bi < message.content.length; bi++) {
256
257
  const block = message.content[bi];
257
- if (block.type === "text") pushBlockRegions(entry, bi, block.text, config, "assistant", out);
258
+ if (block.type === "text") pushBlockRegions(entry, bi, block.text, tokenizer, config, "assistant", out);
258
259
  }
259
260
  return;
260
261
  }
261
262
  if (message.role === "user" || message.role === "developer") {
262
- scanContentBlocks(entry, message.content, config, message.role, out);
263
+ scanContentBlocks(entry, message.content, tokenizer, config, message.role, out);
263
264
  }
264
265
  return;
265
266
  }
266
267
  // custom_message
267
- scanContentBlocks(entry, entry.content, config, entry.customType, out);
268
+ scanContentBlocks(entry, entry.content, tokenizer, config, entry.customType, out);
268
269
  }
269
270
 
270
271
  function scanContentBlocks(
271
272
  entry: SessionMessageEntry | CustomMessageEntry,
272
273
  content: string | Array<{ type: string; text?: string }>,
274
+ tokenizer: Tokenizer,
273
275
  config: ShakeConfig,
274
276
  label: string,
275
277
  out: ShakeRegion[],
276
278
  ): void {
277
279
  if (typeof content === "string") {
278
- pushBlockRegions(entry, -1, content, config, label, out);
280
+ pushBlockRegions(entry, -1, content, tokenizer, config, label, out);
279
281
  return;
280
282
  }
281
283
  for (let bi = 0; bi < content.length; bi++) {
282
284
  const block = content[bi];
283
285
  if (block.type === "text" && typeof block.text === "string") {
284
- pushBlockRegions(entry, bi, block.text, config, label, out);
286
+ pushBlockRegions(entry, bi, block.text, tokenizer, config, label, out);
285
287
  }
286
288
  }
287
289
  }
@@ -300,7 +302,7 @@ function scanContentBlocks(
300
302
  * and regions never span a message boundary. When the combined estimated
301
303
  * savings is below `minSavings`, returns `[]` (no-op).
302
304
  */
303
- export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig): ShakeRegion[] {
305
+ export function collectShakeRegions(entries: SessionEntry[], tokenizer: Tokenizer, config: ShakeConfig): ShakeRegion[] {
304
306
  const n = entries.length;
305
307
  if (n === 0) return [];
306
308
 
@@ -309,7 +311,7 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
309
311
  let acc = 0;
310
312
  for (let i = n - 1; i >= 0; i--) {
311
313
  accumulatedAfter[i] = acc;
312
- acc += entryTokens(entries[i]);
314
+ acc += entryTokens(entries[i], tokenizer);
313
315
  }
314
316
 
315
317
  const toolCallsById = collectToolCallsById(entries);
@@ -342,7 +344,7 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
342
344
  regions.push({
343
345
  kind: "toolResult",
344
346
  entry: entry as SessionMessageEntry,
345
- tokens: estimateTokens(toolResult as AgentMessage),
347
+ tokens: tokenizer.countMessage(toolResult as AgentMessage),
346
348
  originalText: text,
347
349
  label: toolResult.toolName,
348
350
  });
@@ -350,7 +352,7 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
350
352
  }
351
353
 
352
354
  if (entry.type === "message" || entry.type === "custom_message") {
353
- collectBlockRegions(entry as SessionMessageEntry | CustomMessageEntry, config, regions);
355
+ collectBlockRegions(entry as SessionMessageEntry | CustomMessageEntry, tokenizer, config, regions);
354
356
  }
355
357
  }
356
358
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Provider-anchored transcript token accounting.
3
+ *
4
+ * Local tokenization is the expensive way to answer "how big is this
5
+ * conversation?" — and usually the wrong one, because the provider already
6
+ * answered it. Every settled assistant turn carries `usage` covering the exact
7
+ * prompt it was sent: the system prompt, the tool schemas, and every message up
8
+ * to and including itself. The only genuinely unaccounted-for text is the tail
9
+ * appended *after* that turn.
10
+ *
11
+ * These helpers locate the newest trustworthy usage report and tokenize only
12
+ * that tail, so a long session pays counting proportional to one turn instead
13
+ * of to the whole transcript, every turn.
14
+ *
15
+ * Trust rules for an anchor (mirroring the provider contract):
16
+ * - Assistant role only — nothing else carries `usage`.
17
+ * - Not `aborted` / `error`: those turns report partial or zero usage.
18
+ * - `hasContextTokenUsage(usage)`: the report must carry usable context numbers.
19
+ */
20
+
21
+ import type { AssistantMessage } from "@linxiraos/pi-ai";
22
+ import type { MessageCountOptions, Tokenizer } from "../tokenizer";
23
+ import type { AgentMessage } from "../types";
24
+ import { calculateContextTokens, hasContextTokenUsage } from "./compaction";
25
+
26
+ /** A provider usage report that accounts for a prefix of the transcript. */
27
+ export interface TranscriptUsageAnchor {
28
+ /** Index in the scanned array; messages at or before it are provider-accounted. */
29
+ index: number;
30
+ /** The anchoring assistant turn. */
31
+ message: AssistantMessage;
32
+ /** Conversation tokens the provider reported for that prompt. */
33
+ tokens: number;
34
+ }
35
+
36
+ /**
37
+ * Whether this message's provider usage may anchor transcript accounting.
38
+ *
39
+ * The single home for the trust rules — every anchor scan MUST route through
40
+ * it so a stale-usage rule can never drift between the transcript walkers and
41
+ * the session-entry walkers.
42
+ */
43
+ export function isTranscriptUsageAnchor(message: AgentMessage): message is AssistantMessage {
44
+ if (message.role !== "assistant") return false;
45
+ const assistant = message as AssistantMessage;
46
+ if (assistant.stopReason === "aborted" || assistant.stopReason === "error") return false;
47
+ return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
48
+ }
49
+
50
+ /**
51
+ * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
52
+ * transcript, or `undefined` when none qualifies (fresh context, or every
53
+ * recent turn aborted/errored).
54
+ *
55
+ * `fromIndex` excludes turns whose usage is stale — anything a compaction
56
+ * summarized away describes a prompt that is no longer sent.
57
+ */
58
+ export function findTranscriptUsageAnchor(
59
+ messages: readonly AgentMessage[],
60
+ fromIndex = 0,
61
+ ): TranscriptUsageAnchor | undefined {
62
+ for (let index = messages.length - 1; index >= fromIndex; index--) {
63
+ const message = messages[index];
64
+ if (!isTranscriptUsageAnchor(message)) continue;
65
+ return { index, message, tokens: calculateContextTokens(message.usage) };
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /** Options for {@link estimateTranscriptTokens}. */
71
+ export interface TranscriptTokenOptions {
72
+ /**
73
+ * Gates the anchor search only: usage at or before this index is stale (a
74
+ * compaction rewrote the prompt it describes) and must not anchor. Content
75
+ * accounting is governed separately by {@link countFromIndex}.
76
+ */
77
+ anchorFromIndex?: number;
78
+ /**
79
+ * First message whose content is counted locally when no anchor is found.
80
+ * Defaults to 0 (count the whole transcript), which is what a floor
81
+ * estimate wants; pass the compaction boundary to skip summarized-away
82
+ * messages entirely.
83
+ */
84
+ countFromIndex?: number;
85
+ /** Forwarded to {@link Tokenizer.countMessage} for every locally counted message. */
86
+ excludeEncryptedReasoning?: boolean;
87
+ }
88
+
89
+ /**
90
+ * Conversation tokens for `messages`: the provider's own report for everything
91
+ * it already covers, plus a local count of only the unaccounted-for tail.
92
+ *
93
+ * An anchored result already includes the non-message prefix (system prompt +
94
+ * tool schemas) because the provider charged it; an unanchored result is a
95
+ * message-only sum. Callers that add non-message tokens on top MUST branch on
96
+ * {@link findTranscriptUsageAnchor} rather than assuming one shape.
97
+ */
98
+ export function estimateTranscriptTokens(
99
+ messages: readonly AgentMessage[],
100
+ tokenizer: Tokenizer,
101
+ options?: TranscriptTokenOptions,
102
+ ): number {
103
+ const estimateOptions: MessageCountOptions | undefined =
104
+ options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
105
+ const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0);
106
+ let total = anchor?.tokens ?? 0;
107
+ for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
108
+ total += tokenizer.countMessage(messages[index], estimateOptions);
109
+ }
110
+ return total;
111
+ }