@gajae-code/agent-core 0.11.0 → 0.11.2
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 +8 -0
- package/dist/types/agent-loop.d.ts +44 -0
- package/dist/types/agent.d.ts +8 -0
- package/dist/types/compaction/compaction.d.ts +21 -2
- package/dist/types/compaction/pruning.d.ts +6 -2
- package/dist/types/proxy.d.ts +11 -0
- package/dist/types/types.d.ts +8 -2
- package/package.json +4 -4
- package/src/agent-loop.ts +564 -60
- package/src/agent.ts +36 -3
- package/src/compaction/compaction.ts +79 -92
- package/src/compaction/openai.ts +8 -25
- package/src/compaction/pruning.ts +112 -4
- package/src/proxy.ts +82 -2
- package/src/types.ts +4 -1
package/src/agent.ts
CHANGED
|
@@ -308,6 +308,11 @@ interface CursorToolResultEntry {
|
|
|
308
308
|
textLengthAtCall: number;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
export type AgentQueueSnapshot = {
|
|
312
|
+
steering: AgentMessage[];
|
|
313
|
+
followUp: AgentMessage[];
|
|
314
|
+
};
|
|
315
|
+
|
|
311
316
|
export class Agent {
|
|
312
317
|
#state: AgentState = {
|
|
313
318
|
systemPrompt: [],
|
|
@@ -1009,6 +1014,20 @@ export class Agent {
|
|
|
1009
1014
|
this.#followUpQueue = [...messages, ...this.#followUpQueue];
|
|
1010
1015
|
}
|
|
1011
1016
|
|
|
1017
|
+
/** Snapshot both executable queues as one atomic session-level view. */
|
|
1018
|
+
snapshotQueues(): AgentQueueSnapshot {
|
|
1019
|
+
return {
|
|
1020
|
+
steering: this.#steeringQueue.slice(),
|
|
1021
|
+
followUp: this.#followUpQueue.slice(),
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Replace both executable queues with a prior snapshot. */
|
|
1026
|
+
restoreQueues(snapshot: AgentQueueSnapshot): void {
|
|
1027
|
+
this.#steeringQueue = snapshot.steering.slice();
|
|
1028
|
+
this.#followUpQueue = snapshot.followUp.slice();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1012
1031
|
#dequeueSteeringMessages(): AgentMessage[] {
|
|
1013
1032
|
if (this.#steeringMode === "one-at-a-time") {
|
|
1014
1033
|
if (this.#steeringQueue.length > 0) {
|
|
@@ -1607,7 +1626,7 @@ export class Agent {
|
|
|
1607
1626
|
this.requestRunTerminal(managedLogicalRunOwner ?? runId, managedDecision.terminal);
|
|
1608
1627
|
} else if (managedOutcome.type === "run_terminal") {
|
|
1609
1628
|
this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: managedOutcome.reason });
|
|
1610
|
-
} else if (managedDecision?.type !== "retry") {
|
|
1629
|
+
} else if (managedDecision?.type !== "retry" && managedDecision?.type !== "maintenance") {
|
|
1611
1630
|
this.#finalizeRun(managedLogicalRunOwner ?? runId);
|
|
1612
1631
|
}
|
|
1613
1632
|
}
|
|
@@ -1660,7 +1679,10 @@ export class Agent {
|
|
|
1660
1679
|
});
|
|
1661
1680
|
} finally {
|
|
1662
1681
|
let continuation: ManagedAttemptContinuation | undefined;
|
|
1663
|
-
if (
|
|
1682
|
+
if (
|
|
1683
|
+
managedOutcome?.type !== "run_terminal" &&
|
|
1684
|
+
(managedDecision?.type === "retry" || managedDecision?.type === "maintenance")
|
|
1685
|
+
) {
|
|
1664
1686
|
continuation = managedDecision.continuation;
|
|
1665
1687
|
}
|
|
1666
1688
|
const ownership: ManagedAttemptContinuationOwnership = {
|
|
@@ -1691,7 +1713,18 @@ export class Agent {
|
|
|
1691
1713
|
if (continuation && ownership.isCurrent()) {
|
|
1692
1714
|
try {
|
|
1693
1715
|
await continuation(ownership);
|
|
1694
|
-
if (
|
|
1716
|
+
if (
|
|
1717
|
+
managedDecision?.type === "maintenance" &&
|
|
1718
|
+
this.#terminalizedLogicalRunIds.has(managedLogicalRunOwner ?? runId) &&
|
|
1719
|
+
this.#managedLogicalRunOwner === managedLogicalRunOwner
|
|
1720
|
+
) {
|
|
1721
|
+
this.#managedLogicalRunOwner = undefined;
|
|
1722
|
+
}
|
|
1723
|
+
if (
|
|
1724
|
+
managedDecision?.type !== "maintenance" &&
|
|
1725
|
+
this.#activeRunId === undefined &&
|
|
1726
|
+
this.#managedLogicalRunOwner === managedLogicalRunOwner
|
|
1727
|
+
) {
|
|
1695
1728
|
this.#managedLogicalRunOwner = undefined;
|
|
1696
1729
|
}
|
|
1697
1730
|
} catch (err) {
|
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
withOpenAiRemoteCompactionPreserveData,
|
|
30
30
|
} from "./openai";
|
|
31
31
|
import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
|
|
32
|
-
import compactionShortSummaryPrompt from "./prompts/compaction-short-summary.md" with { type: "text" };
|
|
33
32
|
import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { type: "text" };
|
|
34
33
|
import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
|
|
35
34
|
import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
|
|
@@ -144,6 +143,18 @@ export interface CompactionSettings {
|
|
|
144
143
|
remoteEndpoint?: string;
|
|
145
144
|
}
|
|
146
145
|
|
|
146
|
+
export type RemoteCompactionFallbackHealthEvent =
|
|
147
|
+
| { kind: "success"; model: string; provider: string }
|
|
148
|
+
| { kind: "fallback"; model: string; provider: string; error: string };
|
|
149
|
+
|
|
150
|
+
export interface RemoteCompactionFallbackHealthHooks {
|
|
151
|
+
recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isAbortError(error: unknown): boolean {
|
|
155
|
+
return error instanceof Error && error.name === "AbortError";
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
148
159
|
enabled: true,
|
|
149
160
|
strategy: "context-full",
|
|
@@ -498,7 +509,8 @@ function collectMessageFragments(message: AgentMessage): { fragments: string[];
|
|
|
498
509
|
}
|
|
499
510
|
|
|
500
511
|
switch (message.role) {
|
|
501
|
-
case "user":
|
|
512
|
+
case "user":
|
|
513
|
+
case "custom": {
|
|
502
514
|
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
|
503
515
|
if (typeof content === "string") {
|
|
504
516
|
fragments.push(content);
|
|
@@ -698,8 +710,6 @@ export function findCutPoint(
|
|
|
698
710
|
|
|
699
711
|
for (let i = endIndex - 1; i >= startIndex; i--) {
|
|
700
712
|
const entry = entries[i];
|
|
701
|
-
if (entry.type !== "message") continue;
|
|
702
|
-
|
|
703
713
|
// Estimate this message's size
|
|
704
714
|
const messageTokens = estimateEntryTokens(entry);
|
|
705
715
|
accumulatedTokens += messageTokens;
|
|
@@ -757,8 +767,6 @@ const SUMMARIZATION_PROMPT = prompt.render(compactionSummaryPrompt);
|
|
|
757
767
|
|
|
758
768
|
const UPDATE_SUMMARIZATION_PROMPT = prompt.render(compactionUpdateSummaryPrompt);
|
|
759
769
|
|
|
760
|
-
const SHORT_SUMMARY_PROMPT = prompt.render(compactionShortSummaryPrompt);
|
|
761
|
-
|
|
762
770
|
const HANDOFF_DOCUMENT_PROMPT = prompt.render(handoffDocumentPrompt);
|
|
763
771
|
|
|
764
772
|
export const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt);
|
|
@@ -784,8 +792,7 @@ export interface SummaryOptions {
|
|
|
784
792
|
/**
|
|
785
793
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
786
794
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
787
|
-
* `pi.gen_ai.oneshot.kind` (`compaction_summary
|
|
788
|
-
* or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
|
|
795
|
+
* `pi.gen_ai.oneshot.kind` (`compaction_summary` or `compaction_turn_prefix`).
|
|
789
796
|
*/
|
|
790
797
|
telemetry?: AgentTelemetry;
|
|
791
798
|
authCredentialType?: "api_key" | "oauth";
|
|
@@ -799,6 +806,8 @@ export interface SummaryOptions {
|
|
|
799
806
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
800
807
|
/** Hint that websocket transport should be preferred when supported by the provider implementation. */
|
|
801
808
|
preferWebsockets?: boolean;
|
|
809
|
+
/** Session-owned health sink for remote-compaction fallback transition logging. */
|
|
810
|
+
remoteCompactionFallbackHealth?: RemoteCompactionFallbackHealthHooks;
|
|
802
811
|
}
|
|
803
812
|
|
|
804
813
|
/**
|
|
@@ -1040,66 +1049,11 @@ export async function generateHandoff(
|
|
|
1040
1049
|
.join("\n");
|
|
1041
1050
|
}
|
|
1042
1051
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
apiKey: string,
|
|
1049
|
-
signal?: AbortSignal,
|
|
1050
|
-
options?: SummaryOptions,
|
|
1051
|
-
): Promise<string> {
|
|
1052
|
-
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
|
|
1053
|
-
const llmMessages = (options?.convertToLlm ?? convertToLlm)(recentMessages);
|
|
1054
|
-
const conversationText = boundConversationTextForSummary(serializeConversation(llmMessages), model, maxTokens);
|
|
1055
|
-
|
|
1056
|
-
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
|
1057
|
-
if (historySummary) {
|
|
1058
|
-
promptText += `<previous-summary>\n${historySummary}\n</previous-summary>\n\n`;
|
|
1059
|
-
}
|
|
1060
|
-
promptText += formatAdditionalContext(options?.extraContext);
|
|
1061
|
-
promptText += SHORT_SUMMARY_PROMPT;
|
|
1062
|
-
|
|
1063
|
-
if (options?.remoteEndpoint) {
|
|
1064
|
-
const remote = await requestRemoteCompaction(
|
|
1065
|
-
options.remoteEndpoint,
|
|
1066
|
-
{
|
|
1067
|
-
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
|
|
1068
|
-
prompt: promptText,
|
|
1069
|
-
},
|
|
1070
|
-
signal,
|
|
1071
|
-
);
|
|
1072
|
-
return remote.summary;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const response = await instrumentedCompleteSimple(
|
|
1076
|
-
model,
|
|
1077
|
-
{
|
|
1078
|
-
systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
|
|
1079
|
-
messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
|
|
1080
|
-
},
|
|
1081
|
-
{
|
|
1082
|
-
maxTokens,
|
|
1083
|
-
signal,
|
|
1084
|
-
apiKey,
|
|
1085
|
-
reasoning: Effort.High,
|
|
1086
|
-
initiatorOverride: options?.initiatorOverride,
|
|
1087
|
-
metadata: options?.metadata,
|
|
1088
|
-
sessionId: options?.sessionId,
|
|
1089
|
-
providerSessionState: options?.providerSessionState,
|
|
1090
|
-
preferWebsockets: options?.preferWebsockets,
|
|
1091
|
-
},
|
|
1092
|
-
{ telemetry: options?.telemetry, oneshotKind: "compaction_short_summary" },
|
|
1093
|
-
);
|
|
1094
|
-
|
|
1095
|
-
if (response.stopReason === "error") {
|
|
1096
|
-
throw new Error(`Short summary failed: ${response.errorMessage || "Unknown error"}`);
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
return response.content
|
|
1100
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
1101
|
-
.map(c => c.text)
|
|
1102
|
-
.join("\n");
|
|
1052
|
+
/** Derive a display summary locally to avoid a second compaction LLM request. */
|
|
1053
|
+
function deriveShortSummary(summary: string): string {
|
|
1054
|
+
const firstParagraph = summary.trim().split(/\n\s*\n/, 1)[0] ?? "";
|
|
1055
|
+
const maxLength = 2_000;
|
|
1056
|
+
return firstParagraph.length <= maxLength ? firstParagraph : `${firstParagraph.slice(0, maxLength - 1)}…`;
|
|
1103
1057
|
}
|
|
1104
1058
|
|
|
1105
1059
|
// ============================================================================
|
|
@@ -1149,6 +1103,11 @@ export interface PrepareCompactionOptions {
|
|
|
1149
1103
|
* (the confounded raw promptTokens/estimatedTokens quotient is never used).
|
|
1150
1104
|
*/
|
|
1151
1105
|
tokenCorrectionRatio?: number;
|
|
1106
|
+
/**
|
|
1107
|
+
* Model context-window size. Windows below 66k retain the legacy fixed
|
|
1108
|
+
* keepRecentTokens behavior; larger windows scale the keep window to 30%.
|
|
1109
|
+
*/
|
|
1110
|
+
contextWindow?: number;
|
|
1152
1111
|
}
|
|
1153
1112
|
|
|
1154
1113
|
export function prepareCompaction(
|
|
@@ -1179,13 +1138,42 @@ export function prepareCompaction(
|
|
|
1179
1138
|
// counts system+tools+full history while estimatedTokens counted only the
|
|
1180
1139
|
// post-boundary slice, so it was confounded and only ever shrank the window.
|
|
1181
1140
|
// Here the correction is bidirectional and clamped to [0.5, 2].
|
|
1182
|
-
const
|
|
1141
|
+
const configuredKeepRecentTokens = settings.keepRecentTokens;
|
|
1142
|
+
const contextWindow = options.contextWindow;
|
|
1143
|
+
const thresholdSafeKeepRecentTokens =
|
|
1144
|
+
contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow > 1
|
|
1145
|
+
? Math.max(
|
|
1146
|
+
1,
|
|
1147
|
+
resolveThresholdTokens(contextWindow, settings) - effectiveReserveTokens(contextWindow, settings, 0),
|
|
1148
|
+
)
|
|
1149
|
+
: configuredKeepRecentTokens;
|
|
1150
|
+
const keepRecentTokens = Math.min(configuredKeepRecentTokens, thresholdSafeKeepRecentTokens);
|
|
1151
|
+
// Preserve the legacy fixed window for smaller models. At 66k and above,
|
|
1152
|
+
// retain up to 30% of the model context, but never enough to leave the
|
|
1153
|
+
// post-compaction prompt immediately above its configured threshold.
|
|
1154
|
+
const scaledKeepRecentTokens =
|
|
1155
|
+
contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow >= 66_000
|
|
1156
|
+
? Math.min(thresholdSafeKeepRecentTokens, Math.max(keepRecentTokens, Math.floor(contextWindow * 0.3)))
|
|
1157
|
+
: keepRecentTokens;
|
|
1183
1158
|
const rawRatio = options.tokenCorrectionRatio;
|
|
1184
1159
|
const appliedRatio =
|
|
1185
1160
|
rawRatio !== undefined && Number.isFinite(rawRatio) && rawRatio > 0
|
|
1186
1161
|
? Math.min(TOKEN_CORRECTION_MAX_RATIO, Math.max(TOKEN_CORRECTION_MIN_RATIO, rawRatio))
|
|
1187
1162
|
: 1;
|
|
1188
|
-
|
|
1163
|
+
// Preserve an explicit keep floor that already covers the whole history: manual
|
|
1164
|
+
// and emergency callers rely on prepareCompaction returning undefined rather
|
|
1165
|
+
// than manufacturing a summary with no useful reduction. Otherwise, a scaled
|
|
1166
|
+
// window that exceeds a short history falls back to the threshold-safe floor.
|
|
1167
|
+
const historyTokens = pathEntries
|
|
1168
|
+
.slice(boundaryStart, boundaryEnd)
|
|
1169
|
+
.reduce((tokens, entry) => tokens + estimateEntryTokens(entry), 0);
|
|
1170
|
+
const effectiveKeepRecentTokens =
|
|
1171
|
+
configuredKeepRecentTokens > historyTokens
|
|
1172
|
+
? configuredKeepRecentTokens
|
|
1173
|
+
: scaledKeepRecentTokens > keepRecentTokens && scaledKeepRecentTokens > historyTokens
|
|
1174
|
+
? keepRecentTokens
|
|
1175
|
+
: scaledKeepRecentTokens;
|
|
1176
|
+
const keepRecentTokensCorrected = Math.max(1, Math.round(effectiveKeepRecentTokens / appliedRatio));
|
|
1189
1177
|
|
|
1190
1178
|
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokensCorrected);
|
|
1191
1179
|
|
|
@@ -1305,6 +1293,7 @@ export async function compact(
|
|
|
1305
1293
|
sessionId: options?.sessionId,
|
|
1306
1294
|
providerSessionState: options?.providerSessionState,
|
|
1307
1295
|
preferWebsockets: options?.preferWebsockets,
|
|
1296
|
+
remoteCompactionFallbackHealth: options?.remoteCompactionFallbackHealth,
|
|
1308
1297
|
};
|
|
1309
1298
|
|
|
1310
1299
|
let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
|
|
@@ -1331,12 +1320,28 @@ export async function compact(
|
|
|
1331
1320
|
{ authCredentialType: options?.authCredentialType },
|
|
1332
1321
|
);
|
|
1333
1322
|
preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1323
|
+
summaryOptions.remoteCompactionFallbackHealth?.recordRemoteCompactionFallback({
|
|
1324
|
+
kind: "success",
|
|
1337
1325
|
model: model.id,
|
|
1338
1326
|
provider: model.provider,
|
|
1339
1327
|
});
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
if (signal?.aborted || isAbortError(err)) throw err;
|
|
1330
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1331
|
+
if (summaryOptions.remoteCompactionFallbackHealth) {
|
|
1332
|
+
summaryOptions.remoteCompactionFallbackHealth.recordRemoteCompactionFallback({
|
|
1333
|
+
kind: "fallback",
|
|
1334
|
+
error,
|
|
1335
|
+
model: model.id,
|
|
1336
|
+
provider: model.provider,
|
|
1337
|
+
});
|
|
1338
|
+
} else {
|
|
1339
|
+
logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
|
|
1340
|
+
error,
|
|
1341
|
+
model: model.id,
|
|
1342
|
+
provider: model.provider,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1340
1345
|
}
|
|
1341
1346
|
}
|
|
1342
1347
|
}
|
|
@@ -1406,28 +1411,10 @@ export async function compact(
|
|
|
1406
1411
|
summary = "No prior history.";
|
|
1407
1412
|
}
|
|
1408
1413
|
|
|
1409
|
-
const shortSummary = await generateShortSummary(
|
|
1410
|
-
recentMessages,
|
|
1411
|
-
summary,
|
|
1412
|
-
model,
|
|
1413
|
-
settings.reserveTokens,
|
|
1414
|
-
apiKey,
|
|
1415
|
-
signal,
|
|
1416
|
-
{
|
|
1417
|
-
extraContext: options?.extraContext,
|
|
1418
|
-
remoteEndpoint: summaryOptions.remoteEndpoint,
|
|
1419
|
-
initiatorOverride: summaryOptions.initiatorOverride,
|
|
1420
|
-
metadata: summaryOptions.metadata,
|
|
1421
|
-
telemetry: summaryOptions.telemetry,
|
|
1422
|
-
sessionId: summaryOptions.sessionId,
|
|
1423
|
-
providerSessionState: summaryOptions.providerSessionState,
|
|
1424
|
-
preferWebsockets: summaryOptions.preferWebsockets,
|
|
1425
|
-
},
|
|
1426
|
-
);
|
|
1427
|
-
|
|
1428
1414
|
// Compute file lists and append to summary
|
|
1429
1415
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1430
1416
|
summary = upsertFileOperations(summary, readFiles, modifiedFiles);
|
|
1417
|
+
const shortSummary = deriveShortSummary(summary);
|
|
1431
1418
|
|
|
1432
1419
|
if (!firstKeptEntryId) {
|
|
1433
1420
|
throw new Error("First kept entry has no ID - session may need migration");
|
package/src/compaction/openai.ts
CHANGED
|
@@ -514,18 +514,14 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
514
514
|
});
|
|
515
515
|
|
|
516
516
|
if (!response.ok) {
|
|
517
|
-
const errorText = await response.text().catch(() => "");
|
|
518
|
-
logger.warn("OpenAI remote compaction failed", {
|
|
519
|
-
endpoint,
|
|
520
|
-
status: response.status,
|
|
521
|
-
statusText: response.statusText,
|
|
522
|
-
errorText,
|
|
523
|
-
});
|
|
524
517
|
throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
|
|
525
518
|
}
|
|
526
519
|
|
|
527
|
-
const data = (await response.json()) as { output?: unknown
|
|
528
|
-
|
|
520
|
+
const data = (await response.json()) as { output?: unknown } | undefined;
|
|
521
|
+
if (!Array.isArray(data?.output)) {
|
|
522
|
+
throw new Error(`Remote compaction response malformed output (outputType=${typeof data?.output})`);
|
|
523
|
+
}
|
|
524
|
+
const rawOutput = data.output;
|
|
529
525
|
const replacementHistory = rawOutput.filter(
|
|
530
526
|
(item): item is Record<string, unknown> =>
|
|
531
527
|
!!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
|
|
@@ -539,15 +535,9 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
539
535
|
const outputTypes = rawOutput.map(item =>
|
|
540
536
|
typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
|
|
541
537
|
);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
provider: model.provider,
|
|
546
|
-
rawOutputLength: rawOutput.length,
|
|
547
|
-
outputTypes,
|
|
548
|
-
replacementHistoryLength: replacementHistory.length,
|
|
549
|
-
});
|
|
550
|
-
throw new Error("Remote compaction response missing compaction item");
|
|
538
|
+
throw new Error(
|
|
539
|
+
`Remote compaction response missing compaction item (rawOutputLength=${rawOutput.length}, outputTypes=${outputTypes.join(",")}, replacementHistoryLength=${replacementHistory.length})`,
|
|
540
|
+
);
|
|
551
541
|
}
|
|
552
542
|
return { provider: model.provider, replacementHistory, compactionItem };
|
|
553
543
|
}
|
|
@@ -572,13 +562,6 @@ export async function requestRemoteCompaction(
|
|
|
572
562
|
});
|
|
573
563
|
|
|
574
564
|
if (!response.ok) {
|
|
575
|
-
const errorText = await response.text().catch(() => "");
|
|
576
|
-
logger.warn("Remote compaction failed", {
|
|
577
|
-
endpoint,
|
|
578
|
-
status: response.status,
|
|
579
|
-
statusText: response.statusText,
|
|
580
|
-
errorText,
|
|
581
|
-
});
|
|
582
565
|
throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
|
|
583
566
|
}
|
|
584
567
|
|
|
@@ -267,6 +267,56 @@ function readBasePath(path: string): string {
|
|
|
267
267
|
return base;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
type ReadLineRange = { start: number; end: number };
|
|
271
|
+
|
|
272
|
+
const DEFAULT_READ_LINE_LIMIT = 500;
|
|
273
|
+
|
|
274
|
+
/** Parse trailing read selectors using the read tool's actual bounded default. */
|
|
275
|
+
function readLineRanges(path: string): ReadLineRange[] {
|
|
276
|
+
let target = path;
|
|
277
|
+
let raw = false;
|
|
278
|
+
while (/:(?:raw|conflicts)$/.test(target)) {
|
|
279
|
+
raw ||= target.endsWith(":raw");
|
|
280
|
+
target = target.replace(/:(?:raw|conflicts)$/, "");
|
|
281
|
+
}
|
|
282
|
+
const match = target.match(/:(\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/);
|
|
283
|
+
if (!match) return raw ? [{ start: 1, end: Number.POSITIVE_INFINITY }] : [];
|
|
284
|
+
return match[1].split(",").flatMap(part => {
|
|
285
|
+
const range = part.match(/^(\d+)(?:([-+])(\d+))?$/);
|
|
286
|
+
if (!range) return [];
|
|
287
|
+
const start = Number(range[1]);
|
|
288
|
+
const end =
|
|
289
|
+
range[2] === "+"
|
|
290
|
+
? start + Number(range[3]) - 1
|
|
291
|
+
: range[2] === "-"
|
|
292
|
+
? Number(range[3])
|
|
293
|
+
: start + DEFAULT_READ_LINE_LIMIT - 1;
|
|
294
|
+
return start > 0 && end >= start ? [{ start, end }] : [];
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function strictlyContainsReadRange(container: ReadLineRange, contained: ReadLineRange): boolean {
|
|
299
|
+
return (
|
|
300
|
+
container.start <= contained.start &&
|
|
301
|
+
container.end >= contained.end &&
|
|
302
|
+
(container.start < contained.start || container.end > contained.end)
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function readSupersedesRead(
|
|
307
|
+
later: ToolCall,
|
|
308
|
+
earlier: ToolCall,
|
|
309
|
+
lineRangesByCall: ReadonlyMap<ToolCall, ReadLineRange[]>,
|
|
310
|
+
): boolean {
|
|
311
|
+
const laterRanges = lineRangesByCall.get(later);
|
|
312
|
+
const earlierRanges = lineRangesByCall.get(earlier);
|
|
313
|
+
return (
|
|
314
|
+
laterRanges?.length === 1 &&
|
|
315
|
+
earlierRanges?.length === 1 &&
|
|
316
|
+
strictlyContainsReadRange(laterRanges[0], earlierRanges[0])
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
270
320
|
/**
|
|
271
321
|
* Stable identity for "the same logical lookup": same tool re-targeting the
|
|
272
322
|
* same subject. A later result with the same key supersedes earlier ones.
|
|
@@ -275,9 +325,23 @@ function readBasePath(path: string): string {
|
|
|
275
325
|
* (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
|
|
276
326
|
* differently-shaped search complements earlier output, it does not replace it.
|
|
277
327
|
*/
|
|
328
|
+
const IDEMPOTENT_BASH_COMMAND =
|
|
329
|
+
/^(?:(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build)\b|git\s+status\b|cargo\s+build\b|(?:make|just)\s+build\b)/;
|
|
330
|
+
|
|
331
|
+
function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
|
|
332
|
+
if (call.name !== "bash") return undefined;
|
|
333
|
+
const command = call.arguments.command;
|
|
334
|
+
if (typeof command !== "string") return undefined;
|
|
335
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
336
|
+
if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
|
|
337
|
+
return JSON.stringify([normalized, typeof call.arguments.cwd === "string" ? call.arguments.cwd : undefined]);
|
|
338
|
+
}
|
|
339
|
+
|
|
278
340
|
function toolTargetKey(call: ToolCall): string | undefined {
|
|
279
341
|
const path = toolCallPath(call);
|
|
280
342
|
if (path !== undefined) return JSON.stringify([call.name, "path", path]);
|
|
343
|
+
const command = normalizedIdempotentBashCommand(call);
|
|
344
|
+
if (command !== undefined) return JSON.stringify([call.name, "command", command]);
|
|
281
345
|
const pattern = call.arguments.pattern;
|
|
282
346
|
if (typeof pattern === "string" && pattern.length > 0) {
|
|
283
347
|
const paths = call.arguments.paths;
|
|
@@ -372,8 +436,9 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
|
372
436
|
}
|
|
373
437
|
}
|
|
374
438
|
|
|
439
|
+
type ResultMeta = { key?: string; call: ToolCall; message: ToolResultMessage };
|
|
375
440
|
const lastResultIndexByKey = new Map<string, number>();
|
|
376
|
-
const resultMeta = new Map<number,
|
|
441
|
+
const resultMeta = new Map<number, ResultMeta>();
|
|
377
442
|
const lastEditIndexByPath = new Map<string, number>();
|
|
378
443
|
|
|
379
444
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -439,6 +504,31 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
|
439
504
|
}
|
|
440
505
|
}
|
|
441
506
|
|
|
507
|
+
const readsByBasePath = new Map<string, Array<[number, ResultMeta]>>();
|
|
508
|
+
const lineRangesByCall = new Map<ToolCall, ReadLineRange[]>();
|
|
509
|
+
for (const [index, meta] of resultMeta) {
|
|
510
|
+
if (meta.call.name !== "read") continue;
|
|
511
|
+
const path = toolCallPath(meta.call);
|
|
512
|
+
if (!path) continue;
|
|
513
|
+
lineRangesByCall.set(meta.call, readLineRanges(path));
|
|
514
|
+
const basePath = readBasePath(path);
|
|
515
|
+
const group = readsByBasePath.get(basePath);
|
|
516
|
+
if (group) group.push([index, meta]);
|
|
517
|
+
else readsByBasePath.set(basePath, [[index, meta]]);
|
|
518
|
+
}
|
|
519
|
+
for (const reads of readsByBasePath.values()) {
|
|
520
|
+
if (reads.length < 2) continue;
|
|
521
|
+
for (let earlier = 0; earlier < reads.length - 1; earlier++) {
|
|
522
|
+
const [index, meta] = reads[earlier];
|
|
523
|
+
for (let later = earlier + 1; later < reads.length; later++) {
|
|
524
|
+
if (readSupersedesRead(reads[later][1].call, meta.call, lineRangesByCall)) {
|
|
525
|
+
staleResultIndices.add(index);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
442
532
|
return { staleResultIndices };
|
|
443
533
|
}
|
|
444
534
|
export function pruneAssistantToolArguments(
|
|
@@ -596,6 +686,13 @@ function collectToolOutputPruneCandidates(
|
|
|
596
686
|
return { candidates, tokensSaved };
|
|
597
687
|
}
|
|
598
688
|
|
|
689
|
+
function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions = {}): number {
|
|
690
|
+
const relaxedMinimum = options.relaxedMinimum;
|
|
691
|
+
return typeof relaxedMinimum === "number" && Number.isFinite(relaxedMinimum)
|
|
692
|
+
? Math.min(config.minimumSavings, Math.max(0, relaxedMinimum))
|
|
693
|
+
: config.minimumSavings;
|
|
694
|
+
}
|
|
695
|
+
|
|
599
696
|
/**
|
|
600
697
|
* Estimate the token savings {@link pruneToolOutputs} would achieve, without
|
|
601
698
|
* mutating any entry. Returns 0 savings when below the configured minimum so the
|
|
@@ -604,9 +701,10 @@ function collectToolOutputPruneCandidates(
|
|
|
604
701
|
export function estimateToolOutputPruneSavings(
|
|
605
702
|
entries: SessionEntry[],
|
|
606
703
|
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
704
|
+
options: PruneToolOutputsOptions = {},
|
|
607
705
|
): { prunableCount: number; tokensSaved: number } {
|
|
608
706
|
const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
609
|
-
if (tokensSaved < config
|
|
707
|
+
if (tokensSaved < minimumSavings(config, options) || candidates.length === 0) {
|
|
610
708
|
return { prunableCount: 0, tokensSaved: 0 };
|
|
611
709
|
}
|
|
612
710
|
return { prunableCount: candidates.length, tokensSaved };
|
|
@@ -630,10 +728,20 @@ export function shouldRunMaintenancePrune(args: {
|
|
|
630
728
|
return args.estimatedSavings > args.cacheEpochResetCost;
|
|
631
729
|
}
|
|
632
730
|
|
|
633
|
-
export
|
|
731
|
+
export interface PruneToolOutputsOptions {
|
|
732
|
+
/** Lower the usual minimum only when the caller is already over its compaction threshold. */
|
|
733
|
+
relaxedMinimum?: number;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function pruneToolOutputs(
|
|
737
|
+
entries: SessionEntry[],
|
|
738
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
739
|
+
options: PruneToolOutputsOptions = {},
|
|
740
|
+
): PruneResult {
|
|
634
741
|
const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
742
|
+
const minimum = minimumSavings(config, options);
|
|
635
743
|
|
|
636
|
-
if (tokensSaved <
|
|
744
|
+
if (tokensSaved < minimum || candidates.length === 0) {
|
|
637
745
|
return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
|
|
638
746
|
}
|
|
639
747
|
|