@sayknow-cli/agent-core 0.3.16 → 0.4.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 +23 -0
- package/package.json +7 -8
- package/src/agent-loop.ts +826 -40
- package/src/agent.ts +260 -22
- package/src/compaction/compaction.ts +89 -95
- package/src/compaction/entries.ts +19 -0
- package/src/compaction/openai.ts +24 -30
- package/src/compaction/prompts/handoff-document.md +7 -0
- package/src/compaction/pruning.ts +144 -11
- package/src/proxy.ts +82 -2
- package/src/types.ts +106 -3
- package/dist/types/agent-loop.d.ts +0 -56
- package/dist/types/agent.d.ts +0 -403
- package/dist/types/append-only-context.d.ts +0 -137
- package/dist/types/compaction/branch-summarization.d.ts +0 -103
- package/dist/types/compaction/compaction.d.ts +0 -298
- package/dist/types/compaction/entries.d.ts +0 -109
- package/dist/types/compaction/errors.d.ts +0 -26
- package/dist/types/compaction/index.d.ts +0 -11
- package/dist/types/compaction/messages.d.ts +0 -61
- package/dist/types/compaction/openai.d.ts +0 -63
- package/dist/types/compaction/pruning.d.ts +0 -69
- package/dist/types/compaction/utils.d.ts +0 -32
- package/dist/types/compaction.d.ts +0 -1
- package/dist/types/harmony-leak.d.ts +0 -100
- package/dist/types/image-placeholder-guard.d.ts +0 -4
- package/dist/types/index.d.ts +0 -11
- package/dist/types/proxy.d.ts +0 -84
- package/dist/types/run-collector.d.ts +0 -196
- package/dist/types/telemetry.d.ts +0 -596
- package/dist/types/thinking.d.ts +0 -18
- package/dist/types/types.d.ts +0 -430
|
@@ -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
|
/**
|
|
@@ -964,6 +973,12 @@ export interface HandoffOptions {
|
|
|
964
973
|
/** Live agent tool list — same purpose. Forced to `toolChoice: "none"`. */
|
|
965
974
|
tools?: AgentTool<any>[];
|
|
966
975
|
customInstructions?: string;
|
|
976
|
+
/**
|
|
977
|
+
* Optional user-configured extension appended to the base handoff prompt.
|
|
978
|
+
* It SUPPLEMENTS the immutable base (safety/continuity structure); it never
|
|
979
|
+
* replaces `HANDOFF_DOCUMENT_PROMPT`.
|
|
980
|
+
*/
|
|
981
|
+
promptExtension?: string;
|
|
967
982
|
convertToLlm?: ConvertToLlm;
|
|
968
983
|
initiatorOverride?: MessageAttribution;
|
|
969
984
|
metadata?: Record<string, unknown>;
|
|
@@ -984,10 +999,11 @@ export interface HandoffOptions {
|
|
|
984
999
|
preferWebsockets?: boolean;
|
|
985
1000
|
}
|
|
986
1001
|
|
|
987
|
-
export function renderHandoffPrompt(customInstructions?: string): string {
|
|
988
|
-
if (!customInstructions) return HANDOFF_DOCUMENT_PROMPT;
|
|
1002
|
+
export function renderHandoffPrompt(customInstructions?: string, promptExtension?: string): string {
|
|
1003
|
+
if (!customInstructions && !promptExtension) return HANDOFF_DOCUMENT_PROMPT;
|
|
989
1004
|
return prompt.render(handoffDocumentPrompt, {
|
|
990
1005
|
additionalFocus: customInstructions,
|
|
1006
|
+
promptExtension,
|
|
991
1007
|
});
|
|
992
1008
|
}
|
|
993
1009
|
|
|
@@ -1003,7 +1019,7 @@ export async function generateHandoff(
|
|
|
1003
1019
|
...llmMessages,
|
|
1004
1020
|
{
|
|
1005
1021
|
role: "user",
|
|
1006
|
-
content: [{ type: "text", text: renderHandoffPrompt(options.customInstructions) }],
|
|
1022
|
+
content: [{ type: "text", text: renderHandoffPrompt(options.customInstructions, options.promptExtension) }],
|
|
1007
1023
|
attribution: "agent",
|
|
1008
1024
|
timestamp: Date.now(),
|
|
1009
1025
|
},
|
|
@@ -1040,66 +1056,11 @@ export async function generateHandoff(
|
|
|
1040
1056
|
.join("\n");
|
|
1041
1057
|
}
|
|
1042
1058
|
|
|
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");
|
|
1059
|
+
/** Derive a display summary locally to avoid a second compaction LLM request. */
|
|
1060
|
+
function deriveShortSummary(summary: string): string {
|
|
1061
|
+
const firstParagraph = summary.trim().split(/\n\s*\n/, 1)[0] ?? "";
|
|
1062
|
+
const maxLength = 2_000;
|
|
1063
|
+
return firstParagraph.length <= maxLength ? firstParagraph : `${firstParagraph.slice(0, maxLength - 1)}…`;
|
|
1103
1064
|
}
|
|
1104
1065
|
|
|
1105
1066
|
// ============================================================================
|
|
@@ -1149,6 +1110,11 @@ export interface PrepareCompactionOptions {
|
|
|
1149
1110
|
* (the confounded raw promptTokens/estimatedTokens quotient is never used).
|
|
1150
1111
|
*/
|
|
1151
1112
|
tokenCorrectionRatio?: number;
|
|
1113
|
+
/**
|
|
1114
|
+
* Model context-window size. Windows below 66k retain the legacy fixed
|
|
1115
|
+
* keepRecentTokens behavior; larger windows scale the keep window to 30%.
|
|
1116
|
+
*/
|
|
1117
|
+
contextWindow?: number;
|
|
1152
1118
|
}
|
|
1153
1119
|
|
|
1154
1120
|
export function prepareCompaction(
|
|
@@ -1179,13 +1145,42 @@ export function prepareCompaction(
|
|
|
1179
1145
|
// counts system+tools+full history while estimatedTokens counted only the
|
|
1180
1146
|
// post-boundary slice, so it was confounded and only ever shrank the window.
|
|
1181
1147
|
// Here the correction is bidirectional and clamped to [0.5, 2].
|
|
1182
|
-
const
|
|
1148
|
+
const configuredKeepRecentTokens = settings.keepRecentTokens;
|
|
1149
|
+
const contextWindow = options.contextWindow;
|
|
1150
|
+
const thresholdSafeKeepRecentTokens =
|
|
1151
|
+
contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow > 1
|
|
1152
|
+
? Math.max(
|
|
1153
|
+
1,
|
|
1154
|
+
resolveThresholdTokens(contextWindow, settings) - effectiveReserveTokens(contextWindow, settings, 0),
|
|
1155
|
+
)
|
|
1156
|
+
: configuredKeepRecentTokens;
|
|
1157
|
+
const keepRecentTokens = Math.min(configuredKeepRecentTokens, thresholdSafeKeepRecentTokens);
|
|
1158
|
+
// Preserve the legacy fixed window for smaller models. At 66k and above,
|
|
1159
|
+
// retain up to 30% of the model context, but never enough to leave the
|
|
1160
|
+
// post-compaction prompt immediately above its configured threshold.
|
|
1161
|
+
const scaledKeepRecentTokens =
|
|
1162
|
+
contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow >= 66_000
|
|
1163
|
+
? Math.min(thresholdSafeKeepRecentTokens, Math.max(keepRecentTokens, Math.floor(contextWindow * 0.3)))
|
|
1164
|
+
: keepRecentTokens;
|
|
1183
1165
|
const rawRatio = options.tokenCorrectionRatio;
|
|
1184
1166
|
const appliedRatio =
|
|
1185
1167
|
rawRatio !== undefined && Number.isFinite(rawRatio) && rawRatio > 0
|
|
1186
1168
|
? Math.min(TOKEN_CORRECTION_MAX_RATIO, Math.max(TOKEN_CORRECTION_MIN_RATIO, rawRatio))
|
|
1187
1169
|
: 1;
|
|
1188
|
-
|
|
1170
|
+
// Preserve an explicit keep floor that already covers the whole history: manual
|
|
1171
|
+
// and emergency callers rely on prepareCompaction returning undefined rather
|
|
1172
|
+
// than manufacturing a summary with no useful reduction. Otherwise, a scaled
|
|
1173
|
+
// window that exceeds a short history falls back to the threshold-safe floor.
|
|
1174
|
+
const historyTokens = pathEntries
|
|
1175
|
+
.slice(boundaryStart, boundaryEnd)
|
|
1176
|
+
.reduce((tokens, entry) => tokens + estimateEntryTokens(entry), 0);
|
|
1177
|
+
const effectiveKeepRecentTokens =
|
|
1178
|
+
configuredKeepRecentTokens > historyTokens
|
|
1179
|
+
? configuredKeepRecentTokens
|
|
1180
|
+
: scaledKeepRecentTokens > keepRecentTokens && scaledKeepRecentTokens > historyTokens
|
|
1181
|
+
? keepRecentTokens
|
|
1182
|
+
: scaledKeepRecentTokens;
|
|
1183
|
+
const keepRecentTokensCorrected = Math.max(1, Math.round(effectiveKeepRecentTokens / appliedRatio));
|
|
1189
1184
|
|
|
1190
1185
|
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokensCorrected);
|
|
1191
1186
|
|
|
@@ -1305,6 +1300,7 @@ export async function compact(
|
|
|
1305
1300
|
sessionId: options?.sessionId,
|
|
1306
1301
|
providerSessionState: options?.providerSessionState,
|
|
1307
1302
|
preferWebsockets: options?.preferWebsockets,
|
|
1303
|
+
remoteCompactionFallbackHealth: options?.remoteCompactionFallbackHealth,
|
|
1308
1304
|
};
|
|
1309
1305
|
|
|
1310
1306
|
let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
|
|
@@ -1331,12 +1327,28 @@ export async function compact(
|
|
|
1331
1327
|
{ authCredentialType: options?.authCredentialType },
|
|
1332
1328
|
);
|
|
1333
1329
|
preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1330
|
+
summaryOptions.remoteCompactionFallbackHealth?.recordRemoteCompactionFallback({
|
|
1331
|
+
kind: "success",
|
|
1337
1332
|
model: model.id,
|
|
1338
1333
|
provider: model.provider,
|
|
1339
1334
|
});
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
if (signal?.aborted || isAbortError(err)) throw err;
|
|
1337
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1338
|
+
if (summaryOptions.remoteCompactionFallbackHealth) {
|
|
1339
|
+
summaryOptions.remoteCompactionFallbackHealth.recordRemoteCompactionFallback({
|
|
1340
|
+
kind: "fallback",
|
|
1341
|
+
error,
|
|
1342
|
+
model: model.id,
|
|
1343
|
+
provider: model.provider,
|
|
1344
|
+
});
|
|
1345
|
+
} else {
|
|
1346
|
+
logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
|
|
1347
|
+
error,
|
|
1348
|
+
model: model.id,
|
|
1349
|
+
provider: model.provider,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1340
1352
|
}
|
|
1341
1353
|
}
|
|
1342
1354
|
}
|
|
@@ -1406,28 +1418,10 @@ export async function compact(
|
|
|
1406
1418
|
summary = "No prior history.";
|
|
1407
1419
|
}
|
|
1408
1420
|
|
|
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
1421
|
// Compute file lists and append to summary
|
|
1429
1422
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1430
1423
|
summary = upsertFileOperations(summary, readFiles, modifiedFiles);
|
|
1424
|
+
const shortSummary = deriveShortSummary(summary);
|
|
1431
1425
|
|
|
1432
1426
|
if (!firstKeptEntryId) {
|
|
1433
1427
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
@@ -95,6 +95,12 @@ export interface MCPToolSelectionEntry extends SessionEntryBase {
|
|
|
95
95
|
selectedToolNames: string[];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export interface DiscoveredBuiltinToolSelectionEntry extends SessionEntryBase {
|
|
99
|
+
type: "discovered_builtin_tool_selection";
|
|
100
|
+
/** Discoverable built-in tool names selected for visibility in discovery mode. */
|
|
101
|
+
selectedToolNames: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
export interface SessionInitEntry extends SessionEntryBase {
|
|
99
105
|
type: "session_init";
|
|
100
106
|
/** Full system prompt sent to the model */
|
|
@@ -115,6 +121,17 @@ export interface ModeChangeEntry extends SessionEntryBase {
|
|
|
115
121
|
data?: Record<string, unknown>;
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
export interface ConfiguredModelChainEntry extends SessionEntryBase {
|
|
125
|
+
type: "configured_model_chain";
|
|
126
|
+
role: string;
|
|
127
|
+
entries: readonly string[];
|
|
128
|
+
origin: string;
|
|
129
|
+
identity?: string;
|
|
130
|
+
explicitHead: boolean;
|
|
131
|
+
/** Whether this entry removes the configured chain for its role. */
|
|
132
|
+
cleared?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
export interface CustomCompactionSessionEntries {}
|
|
119
136
|
|
|
120
137
|
export type SessionEntry =
|
|
@@ -129,8 +146,10 @@ export type SessionEntry =
|
|
|
129
146
|
| LabelEntry
|
|
130
147
|
| TtsrInjectionEntry
|
|
131
148
|
| MCPToolSelectionEntry
|
|
149
|
+
| DiscoveredBuiltinToolSelectionEntry
|
|
132
150
|
| SessionInitEntry
|
|
133
151
|
| ModeChangeEntry
|
|
152
|
+
| ConfiguredModelChainEntry
|
|
134
153
|
| CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries];
|
|
135
154
|
|
|
136
155
|
export interface ReadonlySessionManager {
|
package/src/compaction/openai.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { AssistantMessage, Message, Model } from "@sayknow-cli/ai/types";
|
|
|
24
24
|
import {
|
|
25
25
|
getOpenAIResponsesHistoryItems,
|
|
26
26
|
getOpenAIResponsesHistoryPayload,
|
|
27
|
+
neutralizeReservedControlTokens,
|
|
28
|
+
neutralizeResponsesInputControlTokens,
|
|
27
29
|
normalizeResponsesToolCallId,
|
|
28
30
|
} from "@sayknow-cli/ai/utils";
|
|
29
31
|
import { $env, logger } from "@sayknow-cli/utils";
|
|
@@ -479,10 +481,12 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
479
481
|
const endpoint = resolveOpenAiCompactEndpoint(model, options?.authCredentialType);
|
|
480
482
|
const request: OpenAiRemoteCompactionRequest = {
|
|
481
483
|
model: model.id,
|
|
482
|
-
input:
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
484
|
+
input: neutralizeResponsesInputControlTokens(
|
|
485
|
+
trimOpenAiCompactInput(
|
|
486
|
+
compactInput,
|
|
487
|
+
resolveOpenAiCompactInputBudget(model.contextWindow, model.maxTokens),
|
|
488
|
+
instructions,
|
|
489
|
+
),
|
|
486
490
|
),
|
|
487
491
|
instructions,
|
|
488
492
|
};
|
|
@@ -510,18 +514,14 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
510
514
|
});
|
|
511
515
|
|
|
512
516
|
if (!response.ok) {
|
|
513
|
-
const errorText = await response.text().catch(() => "");
|
|
514
|
-
logger.warn("OpenAI remote compaction failed", {
|
|
515
|
-
endpoint,
|
|
516
|
-
status: response.status,
|
|
517
|
-
statusText: response.statusText,
|
|
518
|
-
errorText,
|
|
519
|
-
});
|
|
520
517
|
throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
|
|
521
518
|
}
|
|
522
519
|
|
|
523
|
-
const data = (await response.json()) as { output?: unknown
|
|
524
|
-
|
|
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;
|
|
525
525
|
const replacementHistory = rawOutput.filter(
|
|
526
526
|
(item): item is Record<string, unknown> =>
|
|
527
527
|
!!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
|
|
@@ -535,15 +535,9 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
535
535
|
const outputTypes = rawOutput.map(item =>
|
|
536
536
|
typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
|
|
537
537
|
);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
provider: model.provider,
|
|
542
|
-
rawOutputLength: rawOutput.length,
|
|
543
|
-
outputTypes,
|
|
544
|
-
replacementHistoryLength: replacementHistory.length,
|
|
545
|
-
});
|
|
546
|
-
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
|
+
);
|
|
547
541
|
}
|
|
548
542
|
return { provider: model.provider, replacementHistory, compactionItem };
|
|
549
543
|
}
|
|
@@ -553,21 +547,21 @@ export async function requestRemoteCompaction(
|
|
|
553
547
|
request: RemoteCompactionRequest,
|
|
554
548
|
signal?: AbortSignal,
|
|
555
549
|
): Promise<RemoteCompactionResponse> {
|
|
550
|
+
// The prompt embeds the serialized transcript, which can carry leaked Harmony
|
|
551
|
+
// control-token markers (e.g. `<|channel|>analysis`) from model output; a
|
|
552
|
+
// gpt-5.6-backed summarization endpoint rejects those with `Request blocked`.
|
|
553
|
+
const sanitizedRequest: RemoteCompactionRequest = {
|
|
554
|
+
systemPrompt: neutralizeReservedControlTokens(request.systemPrompt),
|
|
555
|
+
prompt: neutralizeReservedControlTokens(request.prompt),
|
|
556
|
+
};
|
|
556
557
|
const response = await fetch(endpoint, {
|
|
557
558
|
method: "POST",
|
|
558
559
|
headers: { "content-type": "application/json" },
|
|
559
|
-
body: JSON.stringify(
|
|
560
|
+
body: JSON.stringify(sanitizedRequest),
|
|
560
561
|
signal,
|
|
561
562
|
});
|
|
562
563
|
|
|
563
564
|
if (!response.ok) {
|
|
564
|
-
const errorText = await response.text().catch(() => "");
|
|
565
|
-
logger.warn("Remote compaction failed", {
|
|
566
|
-
endpoint,
|
|
567
|
-
status: response.status,
|
|
568
|
-
statusText: response.statusText,
|
|
569
|
-
errorText,
|
|
570
|
-
});
|
|
571
565
|
throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
|
|
572
566
|
}
|
|
573
567
|
|
|
@@ -42,6 +42,13 @@ Use exactly this structure:
|
|
|
42
42
|
1. [What should happen next]
|
|
43
43
|
</output>
|
|
44
44
|
|
|
45
|
+
{{#if promptExtension}}
|
|
46
|
+
<instruction>
|
|
47
|
+
Additional handoff guidance (supplements — does not replace — the required structure and critical rules above):
|
|
48
|
+
{{promptExtension}}
|
|
49
|
+
</instruction>
|
|
50
|
+
{{/if}}
|
|
51
|
+
|
|
45
52
|
{{#if additionalFocus}}
|
|
46
53
|
<instruction>
|
|
47
54
|
Additional focus: {{additionalFocus}}
|