@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.
@@ -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`, `compaction_short_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
- async function generateShortSummary(
1044
- recentMessages: AgentMessage[],
1045
- historySummary: string | undefined,
1046
- model: Model,
1047
- reserveTokens: number,
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 keepRecentTokens = settings.keepRecentTokens;
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
- const keepRecentTokensCorrected = Math.max(1, Math.round(keepRecentTokens / appliedRatio));
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
- } catch (err) {
1335
- logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
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 {
@@ -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: trimOpenAiCompactInput(
483
- compactInput,
484
- resolveOpenAiCompactInputBudget(model.contextWindow, model.maxTokens),
485
- instructions,
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[] } | undefined;
524
- const rawOutput = data?.output ?? [];
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
- logger.warn("Remote compaction response missing compaction item", {
539
- endpoint,
540
- model: model.id,
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(request),
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}}