@prometheus-ai/agent-core 0.5.4 → 0.5.8

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.
@@ -267,7 +267,9 @@ function scanContentBlocks(
267
267
  * Walks the protect-recent window (most recent `protectTokens` of context is
268
268
  * kept intact), collects whole tool-result messages (honoring `protectedTools`
269
269
  * and skipping already-pruned results) and large fenced/XML blocks inside
270
- * user/developer/assistant/custom messages. Returns regions in document order.
270
+ * user/developer/assistant/custom messages. Tool results flagged contextually
271
+ * useless by their tool bypass the protect window — there is nothing recent
272
+ * worth keeping in them. Returns regions in document order.
271
273
  *
272
274
  * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
273
275
  * and regions never span a message boundary. When the combined estimated
@@ -289,10 +291,12 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
289
291
 
290
292
  const regions: ShakeRegion[] = [];
291
293
  for (let i = 0; i < n; i++) {
292
- if (accumulatedAfter[i] < config.protectTokens) continue;
293
294
  const entry = entries[i];
294
-
295
295
  const toolResult = getToolResultMessage(entry);
296
+ // Useless-flagged results carry no information once consumed; they are
297
+ // eligible even inside the protect-recent window.
298
+ const uselessResult = toolResult !== undefined && toolResult.useless === true && toolResult.isError !== true;
299
+ if (!uselessResult && accumulatedAfter[i] < config.protectTokens) continue;
296
300
  if (toolResult) {
297
301
  if (toolResult.prunedAt !== undefined) continue;
298
302
  if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Message } from "@prometheus-ai/ai";
6
- import { prompt } from "@prometheus-ai/utils";
6
+ import { formatGroupedPaths, prompt } from "@prometheus-ai/utils";
7
7
  import type { AgentMessage } from "../types";
8
8
  import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
9
9
  import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
@@ -26,6 +26,55 @@ export function createFileOps(): FileOperations {
26
26
  };
27
27
  }
28
28
 
29
+ // Read-tool selector grammar, mirrored from the conservative filesystem splitter in
30
+ // packages/coding-agent/src/tools/path-utils.ts (splitPathAndSel). Keep in sync.
31
+ // A trailing `:chunk` is a selector only when it is a line-range list
32
+ // (`50`, `50-200`, `50+10`, `5-16,960-973`, `..` alias), `raw`, or `conflicts` —
33
+ // alone or as a `range:raw` / `raw:range` compound.
34
+ const RANGE_CHUNK_SRC = String.raw`L?\d+(?:(?:[-+]|\.\.)L?\d+|-|\.\.)?`;
35
+ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
36
+ const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
37
+ const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
38
+ const READ_RAW_ONLY_RE = /^raw$/i;
39
+
40
+ /**
41
+ * Split a read-tool path into its base path and trailing selector, mirroring the
42
+ * read tool's own splitter. Single source of the grammar in this package: the
43
+ * file-operations list strips selectors via {@link stripReadSelector}, and the
44
+ * supersede-prune pass keys on both parts via `readToolSupersedeKey`.
45
+ */
46
+ export function splitReadSelector(path: string): { path: string; sel?: string } {
47
+ const colon = path.lastIndexOf(":");
48
+ if (colon <= 0) return { path };
49
+ const candidate = path.slice(colon + 1);
50
+ if (!READ_SELECTOR_RE.test(candidate)) return { path };
51
+ let base = path.slice(0, colon);
52
+ let sel = candidate;
53
+ // Compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
54
+ const inner = base.lastIndexOf(":");
55
+ if (inner > 0) {
56
+ const innerCandidate = base.slice(inner + 1);
57
+ const innerIsRaw = READ_RAW_ONLY_RE.test(innerCandidate);
58
+ const outerIsRaw = READ_RAW_ONLY_RE.test(candidate);
59
+ const innerIsRange = READ_RANGE_ONLY_RE.test(innerCandidate);
60
+ const outerIsRange = READ_RANGE_ONLY_RE.test(candidate);
61
+ if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
62
+ sel = `${innerCandidate}:${candidate}`;
63
+ base = base.slice(0, inner);
64
+ }
65
+ }
66
+ return { path: base, sel };
67
+ }
68
+
69
+ /**
70
+ * Strip a trailing read-tool selector (`:50-200`, `:raw`, `:1-50:raw`, `:conflicts`, …)
71
+ * so the same file read with different line ranges dedupes to one `<files>` entry
72
+ * and matches its write/edit path when computing Read/Write/RW markers.
73
+ */
74
+ export function stripReadSelector(path: string): string {
75
+ return splitReadSelector(path).path;
76
+ }
77
+
29
78
  /**
30
79
  * Extract file operations from tool calls in an assistant message.
31
80
  */
@@ -46,7 +95,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
46
95
 
47
96
  switch (block.name) {
48
97
  case "read":
49
- fileOps.read.add(path);
98
+ fileOps.read.add(stripReadSelector(path));
50
99
  break;
51
100
  case "write":
52
101
  fileOps.written.add(path);
@@ -70,32 +119,48 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
70
119
  }
71
120
 
72
121
  /**
73
- * Format file operations as XML tags for summary.
122
+ * Format file operations as one `<files>` tag: a grouped, prefix-folded
123
+ * directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
124
+ * ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
125
+ * read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
126
+ * used to tell modified files that were also read (RW) from blind writes.
74
127
  */
75
128
  const FILE_OPERATION_SUMMARY_LIMIT = 20;
76
129
 
77
- function truncateFileList(files: string[]): string[] {
78
- if (files.length <= FILE_OPERATION_SUMMARY_LIMIT) return files;
79
- const omitted = files.length - FILE_OPERATION_SUMMARY_LIMIT;
80
- return [...files.slice(0, FILE_OPERATION_SUMMARY_LIMIT), `… (${omitted} more files omitted)`];
81
- }
82
-
83
130
  function stripFileOperationTags(summary: string): string {
84
- const withoutReadFiles = summary.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "");
85
- const withoutModifiedFiles = withoutReadFiles.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "");
86
- return withoutModifiedFiles.trimEnd();
131
+ // Legacy <read-files>/<modified-files> tags are still stripped so summaries
132
+ // written before the combined <files> tag self-heal on the next compaction.
133
+ return summary
134
+ .replace(/<files>[\s\S]*?<\/files>\s*/g, "")
135
+ .replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
136
+ .replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
137
+ .trimEnd();
87
138
  }
88
- export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
139
+ export function formatFileOperations(
140
+ readFiles: string[],
141
+ modifiedFiles: string[],
142
+ readSet?: ReadonlySet<string>,
143
+ ): string {
89
144
  if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
90
- return prompt.render(fileOperationsTemplate, {
91
- readFiles: truncateFileList(readFiles),
92
- modifiedFiles: truncateFileList(modifiedFiles),
93
- });
145
+ const mode = new Map<string, "Read" | "Write" | "RW">();
146
+ for (const file of readFiles) mode.set(file, "Read");
147
+ for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
148
+ const all = [...mode.keys()].sort();
149
+ let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
150
+ if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
151
+ files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
152
+ }
153
+ return prompt.render(fileOperationsTemplate, { files });
94
154
  }
95
155
 
96
- export function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[]): string {
156
+ export function upsertFileOperations(
157
+ summary: string,
158
+ readFiles: string[],
159
+ modifiedFiles: string[],
160
+ readSet?: ReadonlySet<string>,
161
+ ): string {
97
162
  const baseSummary = stripFileOperationTags(summary);
98
- const fileOperations = formatFileOperations(readFiles, modifiedFiles);
163
+ const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
99
164
  if (!fileOperations) return baseSummary;
100
165
  if (!baseSummary) return fileOperations;
101
166
  return `${baseSummary}\n\n${fileOperations}`;
@@ -126,6 +191,17 @@ function truncateForSummary(text: string, maxChars: number): string {
126
191
  export function serializeConversation(messages: Message[]): string {
127
192
  const parts: string[] = [];
128
193
 
194
+ // Tool results flagged contextually useless (and their paired calls) are
195
+ // dropped from the serialized text: the source region is discarded after
196
+ // summarization anyway, so excluding them costs nothing and keeps garbage
197
+ // out of the summary input.
198
+ const uselessCallIds = new Set<string>();
199
+ for (const msg of messages) {
200
+ if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) {
201
+ uselessCallIds.add(msg.toolCallId);
202
+ }
203
+ }
204
+
129
205
  for (const msg of messages) {
130
206
  if (msg.role === "user") {
131
207
  const content =
@@ -147,6 +223,7 @@ export function serializeConversation(messages: Message[]): string {
147
223
  } else if (block.type === "thinking") {
148
224
  thinkingParts.push(block.thinking);
149
225
  } else if (block.type === "toolCall") {
226
+ if (uselessCallIds.has(block.id)) continue;
150
227
  const args = block.arguments as Record<string, unknown>;
151
228
  const argsStr = Object.entries(args)
152
229
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
@@ -165,6 +242,7 @@ export function serializeConversation(messages: Message[]): string {
165
242
  parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
166
243
  }
167
244
  } else if (msg.role === "toolResult") {
245
+ if (uselessCallIds.has(msg.toolCallId)) continue;
168
246
  const content = msg.content
169
247
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
170
248
  .map(c => c.text)
package/src/proxy.ts CHANGED
@@ -7,17 +7,18 @@ import {
7
7
  type AssistantMessageEvent,
8
8
  type Context,
9
9
  EventStream,
10
+ type FetchImpl,
10
11
  type Model,
11
12
  type SimpleStreamOptions,
12
13
  type StopReason,
13
14
  type ToolCall,
14
15
  } from "@prometheus-ai/ai";
15
- import { calculateCost } from "@prometheus-ai/ai/models";
16
16
  import { parseStreamingJson } from "@prometheus-ai/ai/utils/json-parse";
17
+ import { calculateCost } from "@prometheus-ai/catalog/models";
17
18
  import { readSseJson } from "@prometheus-ai/utils";
18
19
 
19
- // Create stream class matching ProxyMessageEventStream
20
- class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
20
+ // Event stream adapter for proxy SSE events
21
+ export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
21
22
  constructor() {
22
23
  super(
23
24
  event => event.type === "done" || event.type === "error",
@@ -61,6 +62,8 @@ export interface ProxyStreamOptions extends SimpleStreamOptions {
61
62
  authToken: string;
62
63
  /** Proxy server URL (e.g., "https://genai.example.com") */
63
64
  proxyUrl: string;
65
+ /** Optional fetch implementation; defaults to global fetch. */
66
+ fetch?: FetchImpl;
64
67
  }
65
68
 
66
69
  /**
@@ -117,7 +120,7 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
117
120
  }
118
121
 
119
122
  try {
120
- response = await fetch(`${options.proxyUrl}/api/stream`, {
123
+ response = await (options.fetch ?? fetch)(`${options.proxyUrl}/api/stream`, {
121
124
  method: "POST",
122
125
  headers: {
123
126
  Authorization: `Bearer ${options.authToken}`,
@@ -167,9 +170,12 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
167
170
  }
168
171
  }
169
172
 
170
- if (options.signal?.aborted && !sawTerminalEvent) {
171
- const reason = options.signal.reason;
172
- throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
173
+ if (!sawTerminalEvent) {
174
+ if (options.signal?.aborted) {
175
+ const reason = options.signal.reason;
176
+ throw reason instanceof Error ? reason : new Error(String(reason ?? "Request aborted"));
177
+ }
178
+ throw new Error("Proxy stream ended without a terminal event (done or error)");
173
179
  }
174
180
 
175
181
  stream.end();