@martian-engineering/lossless-claw 0.5.2 → 0.6.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/README.md +49 -11
- package/docs/configuration.md +44 -0
- package/openclaw.plugin.json +114 -0
- package/package.json +2 -1
- package/skills/lossless-claw/SKILL.md +33 -0
- package/skills/lossless-claw/references/architecture.md +52 -0
- package/skills/lossless-claw/references/config.md +263 -0
- package/skills/lossless-claw/references/diagnostics.md +79 -0
- package/skills/lossless-claw/references/recall-tools.md +55 -0
- package/skills/lossless-claw/references/session-lifecycle.md +59 -0
- package/src/assembler.ts +321 -34
- package/src/compaction.ts +220 -19
- package/src/db/config.ts +74 -21
- package/src/db/migration.ts +50 -13
- package/src/engine.ts +742 -133
- package/src/plugin/index.ts +156 -73
- package/src/plugin/lcm-command.ts +759 -0
- package/src/plugin/lcm-doctor-apply.ts +546 -0
- package/src/plugin/lcm-doctor-shared.ts +210 -0
- package/src/store/conversation-store.ts +60 -21
- package/src/store/parse-utc-timestamp.ts +25 -0
- package/src/store/summary-store.ts +460 -11
- package/src/summarize.ts +553 -224
- package/src/tools/lcm-expand-query-tool.ts +195 -59
- package/src/tools/lcm-expansion-recursion-guard.ts +87 -0
- package/src/types.ts +1 -0
package/src/assembler.ts
CHANGED
|
@@ -9,6 +9,15 @@ import type { SummaryStore, ContextItemRecord, SummaryRecord } from "./store/sum
|
|
|
9
9
|
|
|
10
10
|
type AgentMessage = Parameters<ContextEngine["ingest"]>[0]["message"];
|
|
11
11
|
|
|
12
|
+
const TOOL_CALL_TYPES = new Set([
|
|
13
|
+
"toolCall",
|
|
14
|
+
"toolUse",
|
|
15
|
+
"tool_use",
|
|
16
|
+
"tool-use",
|
|
17
|
+
"functionCall",
|
|
18
|
+
"function_call",
|
|
19
|
+
]);
|
|
20
|
+
|
|
12
21
|
// ── Public types ─────────────────────────────────────────────────────────────
|
|
13
22
|
|
|
14
23
|
export interface AssembleContextInput {
|
|
@@ -16,6 +25,8 @@ export interface AssembleContextInput {
|
|
|
16
25
|
tokenBudget: number;
|
|
17
26
|
/** Number of most recent raw turns to always include (default: 8) */
|
|
18
27
|
freshTailCount?: number;
|
|
28
|
+
/** Optional user query for relevance-based eviction scoring (BM25-lite). When absent or unsearchable, falls back to chronological eviction. */
|
|
29
|
+
prompt?: string;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
export interface AssembleContextResult {
|
|
@@ -43,10 +54,11 @@ function estimateTokens(text: string): number {
|
|
|
43
54
|
type SummaryPromptSignal = Pick<SummaryRecord, "kind" | "depth" | "descendantCount">;
|
|
44
55
|
|
|
45
56
|
/**
|
|
46
|
-
* Build
|
|
57
|
+
* Build dynamic prompt guidance for compacted session context.
|
|
47
58
|
*
|
|
48
59
|
* Guidance is emitted only when summaries are present in assembled context.
|
|
49
|
-
*
|
|
60
|
+
* Static recall policy lives in the plugin prompt hook so this addition
|
|
61
|
+
* remains session-specific and reflects only the current compaction state.
|
|
50
62
|
*/
|
|
51
63
|
function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): string | undefined {
|
|
52
64
|
if (summarySignals.length === 0) {
|
|
@@ -59,32 +71,24 @@ function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): strin
|
|
|
59
71
|
|
|
60
72
|
const sections: string[] = [];
|
|
61
73
|
|
|
62
|
-
//
|
|
74
|
+
// Dynamic compaction reminder — always present when summaries exist.
|
|
63
75
|
sections.push(
|
|
64
|
-
"##
|
|
65
|
-
"",
|
|
66
|
-
"Summaries above are compressed context — maps to details, not the details themselves.",
|
|
76
|
+
"## Compacted Conversation Context",
|
|
67
77
|
"",
|
|
68
|
-
"
|
|
78
|
+
"Summaries above are compressed context, not full detail.",
|
|
69
79
|
"",
|
|
70
|
-
"
|
|
71
|
-
"1. `lcm_grep` — search by regex or full-text across messages and summaries",
|
|
72
|
-
"2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)",
|
|
73
|
-
"3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer with cited summary IDs (~120s, don't ration it)",
|
|
80
|
+
"Treat summaries as compressed recall cues rather than proof of exact wording or exact values.",
|
|
74
81
|
"",
|
|
75
|
-
"
|
|
76
|
-
"- With IDs: `lcm_expand_query(summaryIds: [\"sum_xxx\"], prompt: \"What config changes were discussed?\")`",
|
|
77
|
-
"- With search: `lcm_expand_query(query: \"database migration\", prompt: \"What strategy was decided?\")`",
|
|
78
|
-
"- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`",
|
|
79
|
-
"",
|
|
80
|
-
"**Summaries include \"Expand for details about:\" footers** listing compressed specifics. Use `lcm_expand_query` with that summary's ID to retrieve them.",
|
|
82
|
+
"If a summary includes an \"Expand for details about:\" footer, use it as a cue to expand before asserting specifics.",
|
|
81
83
|
);
|
|
82
84
|
|
|
83
|
-
// Precision/evidence rules — always present but stronger when heavily compacted
|
|
85
|
+
// Precision/evidence rules — always present but stronger when heavily compacted.
|
|
84
86
|
if (heavilyCompacted) {
|
|
85
87
|
sections.push(
|
|
86
88
|
"",
|
|
87
|
-
"
|
|
89
|
+
"**Deeply compacted context: expand before asserting specifics.**",
|
|
90
|
+
"",
|
|
91
|
+
"Before answering with exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for the missing detail.",
|
|
88
92
|
"",
|
|
89
93
|
"Default recall flow for precision work:",
|
|
90
94
|
"1) `lcm_grep` to locate relevant summary/message IDs",
|
|
@@ -92,19 +96,20 @@ function buildSystemPromptAddition(summarySignals: SummaryPromptSignal[]): strin
|
|
|
92
96
|
"3) Answer with citations to summary IDs used",
|
|
93
97
|
"",
|
|
94
98
|
"**Uncertainty checklist (run before answering):**",
|
|
95
|
-
"- Am I making exact factual
|
|
99
|
+
"- Am I making an exact factual claim from a compressed or condensed summary?",
|
|
96
100
|
"- Could compaction have omitted a crucial detail?",
|
|
97
|
-
"- Would
|
|
101
|
+
"- Would I need an expansion step if the user asks for proof or the exact text?",
|
|
102
|
+
"- Should I state uncertainty instead of asserting specifics until I expand?",
|
|
98
103
|
"",
|
|
99
|
-
"If yes to any
|
|
104
|
+
"If yes to any item, expand first or explicitly say that you need to expand.",
|
|
100
105
|
"",
|
|
101
|
-
"
|
|
106
|
+
"Do not guess exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or explicitly say that you need to expand.",
|
|
102
107
|
);
|
|
103
108
|
} else {
|
|
104
109
|
sections.push(
|
|
105
110
|
"",
|
|
106
|
-
"
|
|
107
|
-
"
|
|
111
|
+
"For exact commands, SHAs, paths, timestamps, config values, or causal chains, expand for details before answering.",
|
|
112
|
+
"State uncertainty instead of guessing from compressed summaries.",
|
|
108
113
|
);
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -267,6 +272,20 @@ export function toolResultBlockFromPart(
|
|
|
267
272
|
rawType?: string,
|
|
268
273
|
raw?: Record<string, unknown>,
|
|
269
274
|
): unknown {
|
|
275
|
+
if (
|
|
276
|
+
raw &&
|
|
277
|
+
typeof raw.text === "string" &&
|
|
278
|
+
raw.output === undefined &&
|
|
279
|
+
raw.content === undefined &&
|
|
280
|
+
(part.toolOutput == null || part.toolOutput === "") &&
|
|
281
|
+
(part.textContent == null || part.textContent === raw.text)
|
|
282
|
+
) {
|
|
283
|
+
return {
|
|
284
|
+
type: "text",
|
|
285
|
+
text: raw.text,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
270
289
|
const type =
|
|
271
290
|
rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
|
|
272
291
|
? rawType
|
|
@@ -454,7 +473,8 @@ export function blockFromPart(part: MessagePartRecord): unknown {
|
|
|
454
473
|
return { type: "text", text: "" };
|
|
455
474
|
}
|
|
456
475
|
|
|
457
|
-
|
|
476
|
+
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
477
|
+
export function contentFromParts(
|
|
458
478
|
parts: MessagePartRecord[],
|
|
459
479
|
role: "user" | "assistant" | "toolResult",
|
|
460
480
|
fallbackContent: string,
|
|
@@ -483,7 +503,8 @@ function contentFromParts(
|
|
|
483
503
|
return blocks;
|
|
484
504
|
}
|
|
485
505
|
|
|
486
|
-
|
|
506
|
+
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
507
|
+
export function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
|
|
487
508
|
for (const part of parts) {
|
|
488
509
|
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
489
510
|
return part.toolCallId;
|
|
@@ -512,7 +533,8 @@ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
|
|
|
512
533
|
return undefined;
|
|
513
534
|
}
|
|
514
535
|
|
|
515
|
-
|
|
536
|
+
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
537
|
+
export function pickToolName(parts: MessagePartRecord[]): string | undefined {
|
|
516
538
|
for (const part of parts) {
|
|
517
539
|
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
518
540
|
return part.toolName;
|
|
@@ -541,7 +563,8 @@ function pickToolName(parts: MessagePartRecord[]): string | undefined {
|
|
|
541
563
|
return undefined;
|
|
542
564
|
}
|
|
543
565
|
|
|
544
|
-
|
|
566
|
+
/** @internal Exported for transcript-maintenance reconstruction. */
|
|
567
|
+
export function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
|
|
545
568
|
for (const part of parts) {
|
|
546
569
|
const decoded = parseJson(part.metadata);
|
|
547
570
|
if (!decoded || typeof decoded !== "object") {
|
|
@@ -555,6 +578,174 @@ function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
|
|
|
555
578
|
return undefined;
|
|
556
579
|
}
|
|
557
580
|
|
|
581
|
+
function extractToolCallId(block: { id?: unknown; call_id?: unknown }): string | null {
|
|
582
|
+
if (typeof block.id === "string" && block.id.length > 0) {
|
|
583
|
+
return block.id;
|
|
584
|
+
}
|
|
585
|
+
if (typeof block.call_id === "string" && block.call_id.length > 0) {
|
|
586
|
+
return block.call_id;
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function extractToolCallIdsFromAssistant(message: AgentMessage): string[] {
|
|
592
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const ids: string[] = [];
|
|
597
|
+
for (const block of message.content) {
|
|
598
|
+
if (!block || typeof block !== "object") {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
|
|
602
|
+
if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const id = extractToolCallId(record);
|
|
606
|
+
if (id) {
|
|
607
|
+
ids.push(id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return ids;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function extractToolResultIdFromMessage(message: AgentMessage): string | null {
|
|
614
|
+
if (!message || typeof message !== "object") {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
if (typeof message.toolCallId === "string" && message.toolCallId.length > 0) {
|
|
618
|
+
return message.toolCallId;
|
|
619
|
+
}
|
|
620
|
+
if (typeof message.toolUseId === "string" && message.toolUseId.length > 0) {
|
|
621
|
+
return message.toolUseId;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function collectAssistantToolCallIds(items: ResolvedItem[]): Set<string> {
|
|
627
|
+
const ids = new Set<string>();
|
|
628
|
+
for (const item of items) {
|
|
629
|
+
for (const id of extractToolCallIdsFromAssistant(item.message)) {
|
|
630
|
+
ids.add(id);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return ids;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function mergeFreshTailWithMatchingToolResults(
|
|
637
|
+
freshTail: ResolvedItem[],
|
|
638
|
+
matchingToolResults: ResolvedItem[],
|
|
639
|
+
): ResolvedItem[] {
|
|
640
|
+
if (matchingToolResults.length === 0) {
|
|
641
|
+
return freshTail;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const resultsById = new Map<string, ResolvedItem[]>();
|
|
645
|
+
for (const item of matchingToolResults) {
|
|
646
|
+
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
647
|
+
if (!toolResultId) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const existing = resultsById.get(toolResultId);
|
|
651
|
+
if (existing) {
|
|
652
|
+
existing.push(item);
|
|
653
|
+
} else {
|
|
654
|
+
resultsById.set(toolResultId, [item]);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const merged: ResolvedItem[] = [];
|
|
659
|
+
const usedOrdinals = new Set<number>();
|
|
660
|
+
|
|
661
|
+
for (const item of freshTail) {
|
|
662
|
+
merged.push(item);
|
|
663
|
+
|
|
664
|
+
const toolCallIds = extractToolCallIdsFromAssistant(item.message);
|
|
665
|
+
if (toolCallIds.length === 0) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const toolCallId of toolCallIds) {
|
|
670
|
+
const matches = resultsById.get(toolCallId);
|
|
671
|
+
if (!matches) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
for (const match of matches) {
|
|
675
|
+
if (usedOrdinals.has(match.ordinal)) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
merged.push(match);
|
|
679
|
+
usedOrdinals.add(match.ordinal);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
for (const item of matchingToolResults) {
|
|
685
|
+
if (!usedOrdinals.has(item.ordinal)) {
|
|
686
|
+
merged.push(item);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return merged;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function filterNonFreshAssistantToolCalls(
|
|
694
|
+
items: ResolvedItem[],
|
|
695
|
+
freshTailOrdinals: Set<number>,
|
|
696
|
+
): AgentMessage[] {
|
|
697
|
+
const availableToolResultIds = new Set<string>();
|
|
698
|
+
for (const item of items) {
|
|
699
|
+
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
700
|
+
if (toolResultId) {
|
|
701
|
+
availableToolResultIds.add(toolResultId);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const filteredMessages: AgentMessage[] = [];
|
|
706
|
+
for (const item of items) {
|
|
707
|
+
if (item.message?.role !== "assistant" || freshTailOrdinals.has(item.ordinal)) {
|
|
708
|
+
filteredMessages.push(item.message);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!Array.isArray(item.message.content)) {
|
|
713
|
+
filteredMessages.push(item.message);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let removedAny = false;
|
|
718
|
+
const content = item.message.content.filter((block) => {
|
|
719
|
+
if (!block || typeof block !== "object") {
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
const record = block as { type?: unknown; id?: unknown; call_id?: unknown };
|
|
723
|
+
if (typeof record.type !== "string" || !TOOL_CALL_TYPES.has(record.type)) {
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
const toolCallId = extractToolCallId(record);
|
|
727
|
+
if (!toolCallId || availableToolResultIds.has(toolCallId)) {
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
removedAny = true;
|
|
731
|
+
return false;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
if (content.length === 0) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (!removedAny) {
|
|
738
|
+
filteredMessages.push(item.message);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
filteredMessages.push({
|
|
742
|
+
...item.message,
|
|
743
|
+
content: content as typeof item.message.content,
|
|
744
|
+
} as AgentMessage);
|
|
745
|
+
}
|
|
746
|
+
return filteredMessages;
|
|
747
|
+
}
|
|
748
|
+
|
|
558
749
|
/** Format a Date for XML attributes in the agent's timezone. */
|
|
559
750
|
function formatDateForAttribute(date: Date, timezone?: string): string {
|
|
560
751
|
const tz = timezone ?? "UTC";
|
|
@@ -632,10 +823,60 @@ interface ResolvedItem {
|
|
|
632
823
|
tokens: number;
|
|
633
824
|
/** Whether this came from a raw message (vs. a summary) */
|
|
634
825
|
isMessage: boolean;
|
|
826
|
+
/** Pre-extracted plain text used for relevance scoring */
|
|
827
|
+
text: string;
|
|
635
828
|
/** Summary metadata used for dynamic system prompt guidance */
|
|
636
829
|
summarySignal?: SummaryPromptSignal;
|
|
637
830
|
}
|
|
638
831
|
|
|
832
|
+
// ── BM25-lite relevance scorer ────────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
/** @internal Exported for testing only. Tokenize text into lowercase alphanumeric terms. */
|
|
835
|
+
export function tokenizeText(text: string): string[] {
|
|
836
|
+
return text
|
|
837
|
+
.toLowerCase()
|
|
838
|
+
.split(/[^a-z0-9]+/)
|
|
839
|
+
.filter((t) => t.length > 1);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* @internal Exported for testing only.
|
|
844
|
+
* Score an item's text against a prompt using BM25-lite (term-frequency overlap).
|
|
845
|
+
* Higher scores indicate stronger keyword overlap. Returns 0 when either input is empty.
|
|
846
|
+
*/
|
|
847
|
+
export function scoreRelevance(itemText: string, prompt: string): number {
|
|
848
|
+
const promptTerms = tokenizeText(prompt);
|
|
849
|
+
if (promptTerms.length === 0) return 0;
|
|
850
|
+
|
|
851
|
+
const itemTerms = tokenizeText(itemText);
|
|
852
|
+
if (itemTerms.length === 0) return 0;
|
|
853
|
+
|
|
854
|
+
// Build term-frequency map for the item
|
|
855
|
+
const freq = new Map<string, number>();
|
|
856
|
+
for (const term of itemTerms) {
|
|
857
|
+
freq.set(term, (freq.get(term) ?? 0) + 1);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Sum TF contribution for each unique prompt term
|
|
861
|
+
const seen = new Set<string>();
|
|
862
|
+
let score = 0;
|
|
863
|
+
for (const term of promptTerms) {
|
|
864
|
+
if (seen.has(term)) continue;
|
|
865
|
+
seen.add(term);
|
|
866
|
+
const tf = freq.get(term) ?? 0;
|
|
867
|
+
if (tf > 0) {
|
|
868
|
+
// Normalised TF: tf / itemLength (BM25-lite saturation skipped for simplicity)
|
|
869
|
+
score += tf / itemTerms.length;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return score;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Return true when a prompt contains at least one searchable term. */
|
|
876
|
+
function hasSearchablePrompt(prompt?: string): prompt is string {
|
|
877
|
+
return typeof prompt === "string" && tokenizeText(prompt).length > 0;
|
|
878
|
+
}
|
|
879
|
+
|
|
639
880
|
// ── ContextAssembler ─────────────────────────────────────────────────────────
|
|
640
881
|
|
|
641
882
|
export class ContextAssembler {
|
|
@@ -692,8 +933,17 @@ export class ContextAssembler {
|
|
|
692
933
|
|
|
693
934
|
// Step 3: Split into evictable prefix and protected fresh tail
|
|
694
935
|
const tailStart = Math.max(0, resolved.length - freshTailCount);
|
|
695
|
-
const
|
|
696
|
-
const
|
|
936
|
+
const baseFreshTail = resolved.slice(tailStart);
|
|
937
|
+
const initialEvictable = resolved.slice(0, tailStart);
|
|
938
|
+
const freshTailOrdinals = new Set(baseFreshTail.map((item) => item.ordinal));
|
|
939
|
+
const tailToolCallIds = collectAssistantToolCallIds(baseFreshTail);
|
|
940
|
+
const tailPairToolResults = initialEvictable.filter((item) => {
|
|
941
|
+
const toolResultId = extractToolResultIdFromMessage(item.message);
|
|
942
|
+
return toolResultId !== null && tailToolCallIds.has(toolResultId);
|
|
943
|
+
});
|
|
944
|
+
const protectedEvictableOrdinals = new Set(tailPairToolResults.map((item) => item.ordinal));
|
|
945
|
+
const evictable = initialEvictable.filter((item) => !protectedEvictableOrdinals.has(item.ordinal));
|
|
946
|
+
const freshTail = mergeFreshTailWithMatchingToolResults(baseFreshTail, tailPairToolResults);
|
|
697
947
|
|
|
698
948
|
// Step 4: Budget-aware selection
|
|
699
949
|
// First, compute the token cost of the fresh tail (always included).
|
|
@@ -719,8 +969,32 @@ export class ContextAssembler {
|
|
|
719
969
|
// Everything fits
|
|
720
970
|
selected.push(...evictable);
|
|
721
971
|
evictableTokens = evictableTotalTokens;
|
|
972
|
+
} else if (hasSearchablePrompt(input.prompt)) {
|
|
973
|
+
// Prompt-aware eviction: score each evictable item by relevance to the
|
|
974
|
+
// prompt, then greedily fill budget from highest-scoring items down.
|
|
975
|
+
// Re-sort selected items by ordinal to restore chronological order.
|
|
976
|
+
const scored = evictable.map((item, idx) => ({
|
|
977
|
+
item,
|
|
978
|
+
score: scoreRelevance(item.text, input.prompt),
|
|
979
|
+
idx, // original index — higher = more recent, used as tiebreaker
|
|
980
|
+
}));
|
|
981
|
+
// Sort: highest relevance first; most recent (higher idx) breaks ties
|
|
982
|
+
scored.sort((a, b) => b.score - a.score || b.idx - a.idx);
|
|
983
|
+
|
|
984
|
+
const kept: ResolvedItem[] = [];
|
|
985
|
+
let accum = 0;
|
|
986
|
+
for (const { item } of scored) {
|
|
987
|
+
if (accum + item.tokens <= remainingBudget) {
|
|
988
|
+
kept.push(item);
|
|
989
|
+
accum += item.tokens;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Restore chronological order by ordinal before appending freshTail
|
|
993
|
+
kept.sort((a, b) => a.ordinal - b.ordinal);
|
|
994
|
+
selected.push(...kept);
|
|
995
|
+
evictableTokens = accum;
|
|
722
996
|
} else {
|
|
723
|
-
//
|
|
997
|
+
// Chronological eviction (default): drop oldest items until we fit.
|
|
724
998
|
// Walk from the END of evictable (newest first) accumulating tokens,
|
|
725
999
|
// then reverse to restore chronological order.
|
|
726
1000
|
const kept: ResolvedItem[] = [];
|
|
@@ -747,7 +1021,7 @@ export class ContextAssembler {
|
|
|
747
1021
|
|
|
748
1022
|
// Normalize assistant string content to array blocks (some providers return
|
|
749
1023
|
// content as a plain string; Anthropic expects content block arrays).
|
|
750
|
-
const rawMessages = selected
|
|
1024
|
+
const rawMessages = filterNonFreshAssistantToolCalls(selected, freshTailOrdinals);
|
|
751
1025
|
for (let i = 0; i < rawMessages.length; i++) {
|
|
752
1026
|
const msg = rawMessages[i];
|
|
753
1027
|
if (msg?.role === "assistant" && typeof msg.content === "string") {
|
|
@@ -758,8 +1032,19 @@ export class ContextAssembler {
|
|
|
758
1032
|
}
|
|
759
1033
|
}
|
|
760
1034
|
|
|
1035
|
+
// Filter out assistant messages with empty content — these can occur when
|
|
1036
|
+
// tool-use-only turns are stored with content="" and zero message_parts,
|
|
1037
|
+
// or when filterNonFreshAssistantToolCalls strips all tool_use blocks.
|
|
1038
|
+
// Anthropic (and other providers) reject empty content arrays/strings.
|
|
1039
|
+
const cleaned = rawMessages.filter(
|
|
1040
|
+
(m) =>
|
|
1041
|
+
!(
|
|
1042
|
+
m?.role === "assistant" &&
|
|
1043
|
+
(Array.isArray(m.content) ? m.content.length === 0 : !m.content)
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
761
1046
|
return {
|
|
762
|
-
messages: sanitizeToolUseResultPairing(
|
|
1047
|
+
messages: sanitizeToolUseResultPairing(cleaned) as AgentMessage[],
|
|
763
1048
|
estimatedTokens,
|
|
764
1049
|
systemPromptAddition,
|
|
765
1050
|
stats: {
|
|
@@ -865,6 +1150,7 @@ export class ContextAssembler {
|
|
|
865
1150
|
} as AgentMessage),
|
|
866
1151
|
tokens: tokenCount,
|
|
867
1152
|
isMessage: true,
|
|
1153
|
+
text: contentText,
|
|
868
1154
|
};
|
|
869
1155
|
}
|
|
870
1156
|
|
|
@@ -887,6 +1173,7 @@ export class ContextAssembler {
|
|
|
887
1173
|
message: { role: "user" as const, content } as AgentMessage,
|
|
888
1174
|
tokens,
|
|
889
1175
|
isMessage: false,
|
|
1176
|
+
text: summary.content,
|
|
890
1177
|
summarySignal: {
|
|
891
1178
|
kind: summary.kind,
|
|
892
1179
|
depth: summary.depth,
|