@linxiraos/pi-agent-core 1.0.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.
- package/CHANGELOG.md +1263 -0
- package/README.md +473 -0
- package/dist/types/agent-loop.d.ts +137 -0
- package/dist/types/agent.d.ts +465 -0
- package/dist/types/append-only-context.d.ts +131 -0
- package/dist/types/compaction/branch-summarization.d.ts +101 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
- package/dist/types/compaction/compaction.d.ts +307 -0
- package/dist/types/compaction/entries.d.ts +111 -0
- package/dist/types/compaction/errors.d.ts +38 -0
- package/dist/types/compaction/index.d.ts +13 -0
- package/dist/types/compaction/message-cache.d.ts +23 -0
- package/dist/types/compaction/messages.d.ts +79 -0
- package/dist/types/compaction/openai.d.ts +117 -0
- package/dist/types/compaction/pruning.d.ts +105 -0
- package/dist/types/compaction/shake.d.ts +97 -0
- package/dist/types/compaction/tool-protection.d.ts +19 -0
- package/dist/types/compaction/utils.d.ts +61 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/pause.d.ts +46 -0
- package/dist/types/proxy.d.ts +91 -0
- package/dist/types/replay-policy.d.ts +5 -0
- package/dist/types/run-collector.d.ts +196 -0
- package/dist/types/telemetry.d.ts +608 -0
- package/dist/types/thinking.d.ts +18 -0
- package/dist/types/tokenizer.d.ts +2 -0
- package/dist/types/types.d.ts +781 -0
- package/dist/types/utils/yield.d.ts +71 -0
- package/package.json +79 -0
- package/src/agent-loop.ts +2926 -0
- package/src/agent.ts +1748 -0
- package/src/append-only-context.ts +348 -0
- package/src/compaction/branch-summarization.ts +369 -0
- package/src/compaction/compaction-v2-streaming.ts +846 -0
- package/src/compaction/compaction.ts +1733 -0
- package/src/compaction/entries.ts +141 -0
- package/src/compaction/errors.ts +47 -0
- package/src/compaction/index.ts +14 -0
- package/src/compaction/message-cache.ts +92 -0
- package/src/compaction/messages.ts +241 -0
- package/src/compaction/openai.ts +992 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/context-window-truncated-output.md +1 -0
- package/src/compaction/prompts/file-operations.md +5 -0
- package/src/compaction/prompts/handoff-document.md +49 -0
- package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +427 -0
- package/src/compaction/shake.ts +446 -0
- package/src/compaction/tool-protection.ts +65 -0
- package/src/compaction/utils.ts +343 -0
- package/src/compaction.ts +1 -0
- package/src/index.ts +26 -0
- package/src/pause.ts +107 -0
- package/src/proxy.ts +391 -0
- package/src/replay-policy.ts +13 -0
- package/src/run-collector.ts +631 -0
- package/src/telemetry.ts +2078 -0
- package/src/thinking.ts +20 -0
- package/src/tokenizer.ts +27 -0
- package/src/types.ts +875 -0
- package/src/utils/yield.ts +183 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for compaction and branch summarization.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Message, ToolCall } from "@linxiraos/pi-ai";
|
|
6
|
+
import { type Dialect, getDialectDefinition } from "@linxiraos/pi-ai/dialect";
|
|
7
|
+
import { escapeHarmonyControlTokens } from "@linxiraos/pi-ai/utils/harmony-leak";
|
|
8
|
+
import { formatGroupedPaths, prompt, stringifyJson } from "@linxiraos/pi-utils";
|
|
9
|
+
import type { AgentMessage } from "../types";
|
|
10
|
+
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
11
|
+
import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// File Operation Tracking
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export interface FileOperations {
|
|
18
|
+
read: Set<string>;
|
|
19
|
+
written: Set<string>;
|
|
20
|
+
edited: Set<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFileOps(): FileOperations {
|
|
24
|
+
return {
|
|
25
|
+
read: new Set(),
|
|
26
|
+
written: new Set(),
|
|
27
|
+
edited: new Set(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read-tool selector grammar, mirrored from the conservative filesystem splitter in
|
|
32
|
+
// packages/coding-agent/src/tools/path-utils.ts (splitPathAndSel). Keep in sync.
|
|
33
|
+
// A trailing `:chunk` is a selector only when it is a line-range list
|
|
34
|
+
// (`50`, `50-200`, `50+10`, `5-16,960-973`, `..` alias), `raw`, or `conflicts` —
|
|
35
|
+
// alone or as a `range:raw` / `raw:range` compound.
|
|
36
|
+
const RANGE_CHUNK_SRC = String.raw`L?\d+(?:(?:[-+]|\.\.)L?\d+|-|\.\.)?`;
|
|
37
|
+
const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
|
|
38
|
+
const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
|
|
39
|
+
const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
|
|
40
|
+
const READ_RAW_ONLY_RE = /^raw$/i;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Split a read-tool path into its base path and trailing selector, mirroring the
|
|
44
|
+
* read tool's own splitter. Single source of the grammar in this package: the
|
|
45
|
+
* file-operations list strips selectors via {@link stripReadSelector}, and the
|
|
46
|
+
* supersede-prune pass keys on both parts via `readToolSupersedeKey`.
|
|
47
|
+
*/
|
|
48
|
+
export function splitReadSelector(path: string): { path: string; sel?: string } {
|
|
49
|
+
const colon = path.lastIndexOf(":");
|
|
50
|
+
if (colon <= 0) return { path };
|
|
51
|
+
const candidate = path.slice(colon + 1);
|
|
52
|
+
if (!READ_SELECTOR_RE.test(candidate)) return { path };
|
|
53
|
+
let base = path.slice(0, colon);
|
|
54
|
+
let sel = candidate;
|
|
55
|
+
// Compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
|
|
56
|
+
const inner = base.lastIndexOf(":");
|
|
57
|
+
if (inner > 0) {
|
|
58
|
+
const innerCandidate = base.slice(inner + 1);
|
|
59
|
+
const innerIsRaw = READ_RAW_ONLY_RE.test(innerCandidate);
|
|
60
|
+
const outerIsRaw = READ_RAW_ONLY_RE.test(candidate);
|
|
61
|
+
const innerIsRange = READ_RANGE_ONLY_RE.test(innerCandidate);
|
|
62
|
+
const outerIsRange = READ_RANGE_ONLY_RE.test(candidate);
|
|
63
|
+
if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
|
|
64
|
+
sel = `${innerCandidate}:${candidate}`;
|
|
65
|
+
base = base.slice(0, inner);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { path: base, sel };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Strip a trailing read-tool selector (`:50-200`, `:raw`, `:1-50:raw`, `:conflicts`, …)
|
|
73
|
+
* so the same file read with different line ranges dedupes to one `<files>` entry
|
|
74
|
+
* and matches its write/edit path when computing Read/Write/RW markers.
|
|
75
|
+
*/
|
|
76
|
+
export function stripReadSelector(path: string): string {
|
|
77
|
+
return splitReadSelector(path).path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A real filesystem path never contains a `scheme://` URL. Tool-call paths that
|
|
82
|
+
* do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
|
|
83
|
+
* `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
|
|
84
|
+
* form — are session-scoped or remote resources, not files the post-compaction
|
|
85
|
+
* agent can re-ground on. Keep them out of the `<files>` summary.
|
|
86
|
+
*/
|
|
87
|
+
const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `path` references a `scheme://` URL (internal URI or web URL) rather
|
|
91
|
+
* than a filesystem path that belongs in the compaction `<files>` summary.
|
|
92
|
+
*/
|
|
93
|
+
export function isUrlSchemePath(path: string): boolean {
|
|
94
|
+
return URL_SCHEME_RE.test(path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract file operations from tool calls in an assistant message.
|
|
99
|
+
*/
|
|
100
|
+
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
|
101
|
+
if (message.role !== "assistant") return;
|
|
102
|
+
if (!("content" in message) || !Array.isArray(message.content)) return;
|
|
103
|
+
|
|
104
|
+
for (const block of message.content) {
|
|
105
|
+
if (typeof block !== "object" || block === null) continue;
|
|
106
|
+
if (!("type" in block) || block.type !== "toolCall") continue;
|
|
107
|
+
if (!("arguments" in block) || !("name" in block)) continue;
|
|
108
|
+
|
|
109
|
+
const args = block.arguments as Record<string, unknown> | undefined;
|
|
110
|
+
if (!args) continue;
|
|
111
|
+
|
|
112
|
+
const path = typeof args.path === "string" ? args.path : undefined;
|
|
113
|
+
if (!path) continue;
|
|
114
|
+
|
|
115
|
+
// Internal URIs (conflict://, artifact://, local://, history://, …) and
|
|
116
|
+
// web URLs are not re-groundable files — keep them out of `<files>`.
|
|
117
|
+
if (isUrlSchemePath(path)) continue;
|
|
118
|
+
|
|
119
|
+
switch (block.name) {
|
|
120
|
+
case "read":
|
|
121
|
+
fileOps.read.add(stripReadSelector(path));
|
|
122
|
+
break;
|
|
123
|
+
case "write":
|
|
124
|
+
fileOps.written.add(path);
|
|
125
|
+
break;
|
|
126
|
+
case "edit":
|
|
127
|
+
fileOps.edited.add(path);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compute final file lists from file operations.
|
|
135
|
+
* Returns readFiles (files only read, not modified) and modifiedFiles.
|
|
136
|
+
*/
|
|
137
|
+
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
|
138
|
+
// Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
|
|
139
|
+
// rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
|
|
140
|
+
// real files belong in `<files>`. New tool-call scans are already filtered.
|
|
141
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
|
|
142
|
+
const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
|
|
143
|
+
const modifiedFiles = [...modified].sort();
|
|
144
|
+
return { readFiles: readOnly, modifiedFiles };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
149
|
+
* directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
|
|
150
|
+
* ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
|
|
151
|
+
* read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
|
|
152
|
+
* used to tell modified files that were also read (RW) from blind writes.
|
|
153
|
+
*/
|
|
154
|
+
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
155
|
+
|
|
156
|
+
function stripFileOperationTags(summary: string): string {
|
|
157
|
+
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
158
|
+
// written before the combined <files> tag self-heal on the next compaction.
|
|
159
|
+
return summary
|
|
160
|
+
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
161
|
+
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
162
|
+
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
163
|
+
.trimEnd();
|
|
164
|
+
}
|
|
165
|
+
export function formatFileOperations(
|
|
166
|
+
readFiles: string[],
|
|
167
|
+
modifiedFiles: string[],
|
|
168
|
+
readSet?: ReadonlySet<string>,
|
|
169
|
+
): string {
|
|
170
|
+
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
171
|
+
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
172
|
+
for (const file of readFiles) mode.set(file, "Read");
|
|
173
|
+
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
174
|
+
const all = [...mode.keys()].sort();
|
|
175
|
+
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
176
|
+
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
177
|
+
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
|
|
178
|
+
}
|
|
179
|
+
return prompt.render(fileOperationsTemplate, { files });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function upsertFileOperations(
|
|
183
|
+
summary: string,
|
|
184
|
+
readFiles: string[],
|
|
185
|
+
modifiedFiles: string[],
|
|
186
|
+
readSet?: ReadonlySet<string>,
|
|
187
|
+
): string {
|
|
188
|
+
const baseSummary = stripFileOperationTags(summary);
|
|
189
|
+
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
190
|
+
if (!fileOperations) return baseSummary;
|
|
191
|
+
if (!baseSummary) return fileOperations;
|
|
192
|
+
return `${baseSummary}\n\n${fileOperations}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// Message Serialization
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
/** Maximum characters for a tool result in serialized summaries. */
|
|
200
|
+
const TOOL_RESULT_MAX_CHARS = 2000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Truncate tool results to the same representation used in summarization prompts.
|
|
204
|
+
*/
|
|
205
|
+
export function truncateToolResultForSummary(text: string): string {
|
|
206
|
+
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
|
207
|
+
const truncatedChars = text.length - TOOL_RESULT_MAX_CHARS;
|
|
208
|
+
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Serialize LLM messages as plain summary input without provider control tokens.
|
|
213
|
+
*/
|
|
214
|
+
export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
|
|
215
|
+
const conversation = serializeConversation(messages, dialect);
|
|
216
|
+
if (dialect !== "harmony") return conversation;
|
|
217
|
+
return escapeHarmonyControlTokens(conversation);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Serialize LLM messages to transcript text.
|
|
222
|
+
* Call convertToLlm() first to handle custom message types.
|
|
223
|
+
*/
|
|
224
|
+
export function serializeConversation(messages: Message[], dialect?: Dialect): string {
|
|
225
|
+
// Tool results flagged contextually useless (and their paired calls) are
|
|
226
|
+
// dropped from the serialized text: the source region is discarded after
|
|
227
|
+
// summarization anyway, so excluding them costs nothing and keeps garbage
|
|
228
|
+
// out of the summary input.
|
|
229
|
+
const uselessCallIds = new Set<string>();
|
|
230
|
+
for (const msg of messages) {
|
|
231
|
+
if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) {
|
|
232
|
+
uselessCallIds.add(msg.toolCallId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (dialect) {
|
|
236
|
+
// Claude's classifier refuses inputs that reproduce the model's own
|
|
237
|
+
// reasoning as text ("reasoning_extraction"), and the anthropic dialect
|
|
238
|
+
// otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
|
|
239
|
+
// ephemeral and low-signal for a summary, so drop it from Anthropic-target
|
|
240
|
+
// summary input. Other dialects (e.g. Harmony) carry reasoning natively in
|
|
241
|
+
// their transcript format and keep it.
|
|
242
|
+
const dropThinking = dialect === "anthropic";
|
|
243
|
+
const processed: Message[] = [];
|
|
244
|
+
for (const msg of messages) {
|
|
245
|
+
if (msg.role === "assistant") {
|
|
246
|
+
const content = msg.content.filter(
|
|
247
|
+
block =>
|
|
248
|
+
(block.type !== "toolCall" || !uselessCallIds.has(block.id)) &&
|
|
249
|
+
(!dropThinking || block.type !== "thinking"),
|
|
250
|
+
);
|
|
251
|
+
if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (msg.role === "toolResult") {
|
|
255
|
+
if (uselessCallIds.has(msg.toolCallId)) continue;
|
|
256
|
+
const text = msg.content
|
|
257
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
258
|
+
.map(c => c.text)
|
|
259
|
+
.join("");
|
|
260
|
+
if (!text) continue;
|
|
261
|
+
processed.push({
|
|
262
|
+
...msg,
|
|
263
|
+
content: [{ type: "text", text: truncateToolResultForSummary(text) }],
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
processed.push(msg);
|
|
268
|
+
}
|
|
269
|
+
return getDialectDefinition(dialect).renderTranscript(processed);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parts: string[] = [];
|
|
273
|
+
for (const msg of messages) {
|
|
274
|
+
if (msg.role === "user") {
|
|
275
|
+
const content =
|
|
276
|
+
typeof msg.content === "string"
|
|
277
|
+
? msg.content
|
|
278
|
+
: msg.content
|
|
279
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
280
|
+
.map(c => c.text)
|
|
281
|
+
.join("");
|
|
282
|
+
if (content) parts.push(`[User]: ${content}`);
|
|
283
|
+
} else if (msg.role === "assistant") {
|
|
284
|
+
const textParts: string[] = [];
|
|
285
|
+
const thinkingParts: string[] = [];
|
|
286
|
+
const toolCalls: ToolCall[] = [];
|
|
287
|
+
|
|
288
|
+
for (const block of msg.content) {
|
|
289
|
+
if (block.type === "text") {
|
|
290
|
+
textParts.push(block.text);
|
|
291
|
+
} else if (block.type === "thinking") {
|
|
292
|
+
thinkingParts.push(block.thinking);
|
|
293
|
+
} else if (block.type === "toolCall") {
|
|
294
|
+
if (uselessCallIds.has(block.id)) continue;
|
|
295
|
+
toolCalls.push(block);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (thinkingParts.length > 0) {
|
|
300
|
+
parts.push(`[Think]: ${thinkingParts.join("\n")}`);
|
|
301
|
+
}
|
|
302
|
+
if (textParts.length > 0) {
|
|
303
|
+
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
304
|
+
}
|
|
305
|
+
if (toolCalls.length > 0) {
|
|
306
|
+
parts.push(`[Tool Call]: ${renderToolCalls(toolCalls)}`);
|
|
307
|
+
}
|
|
308
|
+
} else if (msg.role === "toolResult") {
|
|
309
|
+
if (uselessCallIds.has(msg.toolCallId)) continue;
|
|
310
|
+
const content = msg.content
|
|
311
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
312
|
+
.map(c => c.text)
|
|
313
|
+
.join("");
|
|
314
|
+
if (content) {
|
|
315
|
+
const text = truncateToolResultForSummary(content);
|
|
316
|
+
parts.push(`[Tool Result]: ${text}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return parts.join("\n\n");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Render an assistant turn's tool calls as a compact `name(args)` list for the
|
|
326
|
+
* legacy serializer.
|
|
327
|
+
*/
|
|
328
|
+
function renderToolCalls(calls: ToolCall[]): string {
|
|
329
|
+
return calls
|
|
330
|
+
.map(call => {
|
|
331
|
+
const argsStr = Object.entries(call.arguments as Record<string, unknown>)
|
|
332
|
+
.map(([k, v]) => `${k}=${stringifyJson(v) ?? "null"}`)
|
|
333
|
+
.join(", ");
|
|
334
|
+
return `${call.name}(${argsStr})`;
|
|
335
|
+
})
|
|
336
|
+
.join("; ");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// Summarization System Prompt
|
|
341
|
+
// ============================================================================
|
|
342
|
+
|
|
343
|
+
export const SUMMARIZATION_SYSTEM_PROMPT = prompt.render(summarizationSystemPrompt);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./compaction/index";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Core Agent
|
|
2
|
+
export * from "./agent";
|
|
3
|
+
// Loop functions
|
|
4
|
+
export * from "./agent-loop";
|
|
5
|
+
// Append-only context mode
|
|
6
|
+
export * from "./append-only-context";
|
|
7
|
+
// Compaction
|
|
8
|
+
export * from "./compaction";
|
|
9
|
+
// Process-global pause gate
|
|
10
|
+
export * from "./pause";
|
|
11
|
+
// Proxy utilities
|
|
12
|
+
export * from "./proxy";
|
|
13
|
+
// Replay policy
|
|
14
|
+
export * from "./replay-policy";
|
|
15
|
+
// Run-level telemetry collector + aggregators
|
|
16
|
+
export * from "./run-collector";
|
|
17
|
+
// Telemetry
|
|
18
|
+
export * from "./telemetry";
|
|
19
|
+
// Thinking selectors
|
|
20
|
+
export * from "./thinking";
|
|
21
|
+
// Tokenizer choice
|
|
22
|
+
export * from "./tokenizer";
|
|
23
|
+
// Types
|
|
24
|
+
export * from "./types";
|
|
25
|
+
// Yield utilities for Bun event-loop busy-wait prevention
|
|
26
|
+
export * from "./utils/yield";
|
package/src/pause.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-global pause gate for agent loops.
|
|
3
|
+
*
|
|
4
|
+
* Every agent in a process — main session, in-process subagents, advisor —
|
|
5
|
+
* funnels through {@link ../agent-loop!agentLoop}, which polls this gate at its
|
|
6
|
+
* two action boundaries: before each model call and before each tool call
|
|
7
|
+
* starts. Engaging the gate therefore freezes all of them at the next safe
|
|
8
|
+
* point without aborting anything: in-flight provider streams and
|
|
9
|
+
* already-started tool executions run to completion, then every loop parks
|
|
10
|
+
* until {@link AgentPauseGate.resume}. Queued steering/follow-up messages stay
|
|
11
|
+
* queued and deliver normally after resume.
|
|
12
|
+
*
|
|
13
|
+
* A run's own `AbortSignal` still unwinds a parked loop immediately: the park
|
|
14
|
+
* releases on abort (without releasing the gate), so cancelling one run never
|
|
15
|
+
* requires resuming the whole process.
|
|
16
|
+
*
|
|
17
|
+
* Hosts drive the singleton {@link agentPauseGate} (e.g. the TUI `/pause`
|
|
18
|
+
* command); library code only ever reads it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Listener invoked with the new state on every pause/resume transition. */
|
|
22
|
+
export type AgentPauseListener = (paused: boolean) => void;
|
|
23
|
+
|
|
24
|
+
/** Freeze switch shared by every agent loop in the process. See module docs. */
|
|
25
|
+
export class AgentPauseGate {
|
|
26
|
+
/** Pending while paused; resolved and cleared on resume. */
|
|
27
|
+
#gate: PromiseWithResolvers<void> | undefined;
|
|
28
|
+
#pausedAt = 0;
|
|
29
|
+
#listeners = new Set<AgentPauseListener>();
|
|
30
|
+
|
|
31
|
+
/** True while the gate is engaged. */
|
|
32
|
+
get paused(): boolean {
|
|
33
|
+
return this.#gate !== undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Epoch ms when the current pause began; undefined when running. */
|
|
37
|
+
get pausedAt(): number | undefined {
|
|
38
|
+
return this.#gate ? this.#pausedAt : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Engage the gate. Returns false (and does nothing) when already paused. */
|
|
42
|
+
pause(): boolean {
|
|
43
|
+
if (this.#gate) return false;
|
|
44
|
+
this.#gate = Promise.withResolvers<void>();
|
|
45
|
+
this.#pausedAt = Date.now();
|
|
46
|
+
this.#notify(true);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Release the gate, waking every parked loop. Returns the pause duration in
|
|
52
|
+
* ms, or undefined when the gate was not engaged.
|
|
53
|
+
*/
|
|
54
|
+
resume(): number | undefined {
|
|
55
|
+
const gate = this.#gate;
|
|
56
|
+
if (!gate) return undefined;
|
|
57
|
+
this.#gate = undefined;
|
|
58
|
+
gate.resolve();
|
|
59
|
+
this.#notify(false);
|
|
60
|
+
return Date.now() - this.#pausedAt;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Subscribe to pause/resume transitions. Returns an unsubscribe function. */
|
|
64
|
+
onChange(listener: AgentPauseListener): () => void {
|
|
65
|
+
this.#listeners.add(listener);
|
|
66
|
+
return () => this.#listeners.delete(listener);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Park until the gate is released. Resolves immediately when not paused.
|
|
71
|
+
* An abort on `signal` releases only this wait — the gate stays engaged —
|
|
72
|
+
* so a cancelled run unwinds while the rest of the process stays frozen.
|
|
73
|
+
*/
|
|
74
|
+
async waitUntilResumed(signal?: AbortSignal): Promise<void> {
|
|
75
|
+
// Loop: resume() swaps the gate promise, so a pause re-engaged while a
|
|
76
|
+
// waiter is between awaits must re-park instead of slipping through.
|
|
77
|
+
while (this.#gate) {
|
|
78
|
+
if (signal?.aborted) return;
|
|
79
|
+
const gate = this.#gate.promise;
|
|
80
|
+
if (!signal) {
|
|
81
|
+
await gate;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const abort = Promise.withResolvers<void>();
|
|
85
|
+
const onAbort = () => abort.resolve();
|
|
86
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
87
|
+
try {
|
|
88
|
+
await Promise.race([gate, abort.promise]);
|
|
89
|
+
} finally {
|
|
90
|
+
signal.removeEventListener("abort", onAbort);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#notify(paused: boolean): void {
|
|
96
|
+
for (const listener of this.#listeners) {
|
|
97
|
+
try {
|
|
98
|
+
listener(paused);
|
|
99
|
+
} catch {
|
|
100
|
+
// Host UI listeners must never break the gate.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The process-wide gate polled by the agent loop. */
|
|
107
|
+
export const agentPauseGate = new AgentPauseGate();
|