caching and ctx window optimizations
frdel committed
Feb 11, 2026 at 23:04 UTC
3dceaca64ec49d4149b9ae91a0e8e66a4692d132
7 files changed
+82
-39
agent.py
+2
@@ -798,6 +798,7 @@ class Agent:
798
response_callback: Callable[[str, str], Awaitable[None]] | None = None,
799
reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
800
background: bool = False,
801
+ explicit_caching: bool = True,
802
):
803
response = ""
804
@@ -812,6 +813,7 @@ class Agent:
813
rate_limiter_callback=(
814
self.rate_limiter_callback if not background else None
815
),
816
+ explicit_caching=explicit_caching,
817
)
818
819
return response, reasoning
models.py
+12
-2
@@ -316,7 +316,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
316
def _llm_type(self) -> str:
317
return "litellm-chat"
318
319
- def _convert_messages(self, messages: List[BaseMessage]) -> List[dict]:
319
+ def _convert_messages(self, messages: List[BaseMessage], explicit_caching: bool = False) -> List[dict]:
320
result = []
321
# Map LangChain message types to LiteLLM roles
322
role_mapping = {
@@ -362,6 +362,15 @@ class LiteLLMChatWrapper(SimpleChatModel):
362
message_dict["tool_call_id"] = tool_call_id
363
364
result.append(message_dict)
365
+
366
+ if explicit_caching and result:
367
+ if result[0]["role"] == "system":
368
+ result[0]["cache_control"] = {"type": "ephemeral"}
369
+ for i in range(len(result) - 1, -1, -1):
370
+ if result[i]["role"] == "assistant":
371
+ result[i]["cache_control"] = {"type": "ephemeral"}
372
+ break
373
+
374
return result
375
376
def _call(
@@ -464,6 +473,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
473
rate_limiter_callback: (
474
Callable[[str, str, int, int], Awaitable[bool]] | None
475
) = None,
476
+ explicit_caching: bool = False,
477
**kwargs: Any,
478
) -> Tuple[str, str]:
479
@@ -478,7 +488,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
488
messages.append(HumanMessage(content=user_message))
489
490
# convert to litellm format
481
- msgs_conv = self._convert_messages(messages)
491
+ msgs_conv = self._convert_messages(messages, explicit_caching=explicit_caching)
492
493
# Apply rate limiting if configured
494
limiter = await apply_rate_limiter(
prompts/fw.topic_summary.sys.md
+2
-1
@@ -6,7 +6,8 @@ You must return a single summary of all records
6
7
# Expected output
8
Your output will be a text of the summary
9
-Length of the text should be one paragraph, approximately 100 words
9
+Summary must be shorter than original messages
10
+Length of the text should be maximum one paragraph, approximately 100 words, shorter if original is shorter
11
No intro
12
No conclusion
13
No formatting
python/extensions/message_loop_prompts_after/_50_recall_memories.py
+5
-1
@@ -8,6 +8,7 @@ from python.helpers import dirty_json, errors, settings, log
8
9
DATA_NAME_TASK = "_recall_memories_task"
10
DATA_NAME_ITER = "_recall_memories_iter"
11
+SEARCH_TIMEOUT = 30
12
13
14
class RecallMemories(Extension):
@@ -38,7 +39,10 @@ class RecallMemories(Extension):
39
)
40
41
task = asyncio.create_task(
41
- self.search_memories(loop_data=loop_data, log_item=log_item, **kwargs)
42
+ asyncio.wait_for(
43
+ self.search_memories(loop_data=loop_data, log_item=log_item, **kwargs),
44
+ timeout=SEARCH_TIMEOUT,
45
+ )
46
)
47
else:
48
task = None
python/helpers/document_query.py
+2
-1
@@ -433,7 +433,8 @@ class DocumentQueryHelper:
433
messages=[
434
SystemMessage(content=qa_system_message),
435
HumanMessage(content=qa_user_message),
436
- ]
436
+ ],
437
+ explicit_caching=False,
438
)
439
440
self.progress_callback(f"Q&A process completed")
python/helpers/history.py
+58
-33
@@ -10,13 +10,16 @@ from enum import Enum
10
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
11
12
BULK_MERGE_COUNT = 3
13
-TOPICS_KEEP_COUNT = 3
13
+TOPICS_MERGE_COUNT = 3
14
CURRENT_TOPIC_RATIO = 0.5
15
HISTORY_TOPIC_RATIO = 0.3
16
HISTORY_BULK_RATIO = 0.2
17
-TOPIC_COMPRESS_RATIO = 0.65
18
-LARGE_MESSAGE_TO_TOPIC_RATIO = 0.5
17
+CURRENT_TOPIC_ATTENTION_COMPRESSION = 0.65 # compress current topic's attention window to 65% of size
18
+HISTORY_TOPIC_ATTENTION_COMPRESSION = 0 # compress history topic's attention window to 0% of size - only request and response remain intact
19
+LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO = 0.5
20
+LARGE_MESSAGE_TO_HISTORY_TOPIC_RATIO = 0.2
21
RAW_MESSAGE_OUTPUT_TEXT_TRIM = 100
22
+COMPRESSION_TARGET_RATIO = 0.8
23
24
25
class RawMessage(TypedDict):
@@ -155,13 +158,12 @@ class Topic(Record):
158
self.summary = await self.summarize_messages(self.messages)
159
return self.summary
160
158
- async def compress_large_messages(self) -> bool:
161
+ def compress_large_messages(self, message_ratio: float = CURRENT_TOPIC_RATIO * LARGE_MESSAGE_TO_CURRENT_TOPIC_RATIO) -> bool:
162
set = settings.get_settings()
163
msg_max_size = (
164
set["chat_model_ctx_length"]
165
* set["chat_model_ctx_history"]
163
- * CURRENT_TOPIC_RATIO
164
- * LARGE_MESSAGE_TO_TOPIC_RATIO
166
+ * message_ratio
167
)
168
large_msgs = []
169
for m in (m for m in self.messages if not m.summary):
@@ -195,27 +197,29 @@ class Topic(Record):
197
return False
198
199
async def compress(self) -> bool:
198
- compress = await self.compress_large_messages()
200
+ compress = self.compress_large_messages()
201
if not compress:
202
compress = await self.compress_attention()
203
return compress
204
203
- async def compress_attention(self) -> bool:
205
+ async def compress_attention(self, ratio: float = CURRENT_TOPIC_ATTENTION_COMPRESSION) -> bool:
206
205
- if len(self.messages) > 2:
206
- cnt_to_sum = math.ceil((len(self.messages) - 2) * TOPIC_COMPRESS_RATIO)
207
- msg_to_sum = self.messages[1 : cnt_to_sum + 1]
208
- summary = await self.summarize_messages(msg_to_sum)
209
- sum_msg_content = self.history.agent.parse_prompt(
210
- "fw.msg_summary.md", summary=summary
211
- )
212
- sum_msg = Message(False, sum_msg_content)
213
- self.messages[1 : cnt_to_sum + 1] = [sum_msg]
214
- return True
215
- return False
207
+ middle = len(self.messages) - 2
208
+ if middle < 2:
209
+ return False
210
+ cnt_to_sum = middle - math.floor(middle * ratio)
211
+ if cnt_to_sum < 1:
212
+ return False
213
+ msg_to_sum = self.messages[1 : cnt_to_sum + 1]
214
+ summary = await self.summarize_messages(msg_to_sum)
215
+ sum_msg_content = self.history.agent.parse_prompt(
216
+ "fw.msg_summary.md", summary=summary
217
+ )
218
+ sum_msg = Message(False, sum_msg_content)
219
+ self.messages[1 : cnt_to_sum + 1] = [sum_msg]
220
+ return True
221
222
async def summarize_messages(self, messages: list[Message]):
218
- # FIXME: vision bytes are sent to utility LLM, send summary instead
223
msg_txt = [m.output_text() for m in messages]
224
summary = await self.history.agent.call_utility_model(
225
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
@@ -363,22 +367,38 @@ class History(Record):
367
368
async def compress(self):
369
compressed = False
370
+ total = _get_ctx_size_for_history()
371
+ curr, hist, bulk = (
372
+ self.get_current_topic_tokens(),
373
+ self.get_topics_tokens(),
374
+ self.get_bulks_tokens(),
375
+ )
376
+ if (curr + hist + bulk) <= total:
377
+ return False
378
+
379
+ target = total * COMPRESSION_TARGET_RATIO
380
+ prev_total = curr + hist + bulk
381
while True:
382
curr, hist, bulk = (
383
self.get_current_topic_tokens(),
384
self.get_topics_tokens(),
385
self.get_bulks_tokens(),
386
)
372
- total = _get_ctx_size_for_history()
387
+
388
+ # safeguard against infinite loop in case LLM bloats the summary for some reason
389
+ if (curr + hist + bulk) >= prev_total:
390
+ break
391
+ prev_total = curr + hist + bulk
392
+
393
ratios = [
394
(curr, CURRENT_TOPIC_RATIO, "current_topic"),
395
(hist, HISTORY_TOPIC_RATIO, "history_topic"),
396
(bulk, HISTORY_BULK_RATIO, "history_bulk"),
397
]
378
- ratios = sorted(ratios, key=lambda x: (x[0] / total) / x[1], reverse=True)
398
+ ratios = sorted(ratios, key=lambda x: (x[0] / target) / x[1], reverse=True)
399
compressed_part = False
400
for ratio in ratios:
381
- if ratio[0] > ratio[1] * total:
401
+ if ratio[0] > ratio[1] * target:
402
over_part = ratio[2]
403
if over_part == "current_topic":
404
compressed_part = await self.current.compress()
@@ -394,24 +414,29 @@ class History(Record):
414
continue
415
else:
416
return compressed
417
+ return compressed
418
419
async def compress_topics(self) -> bool:
399
- # summarize topics one by one
420
+
421
+ # 1. first identify large messages and compress them cheaply
422
for topic in self.topics:
401
- if not topic.summary:
402
- await topic.summarize()
423
+ if topic.compress_large_messages(HISTORY_TOPIC_RATIO*LARGE_MESSAGE_TO_HISTORY_TOPIC_RATIO):
424
return True
425
405
- # move oldest topic to bulks and summarize
426
+ # 2. summarize topics attention window one by one
427
for topic in self.topics:
428
+ if await topic.compress_attention(HISTORY_TOPIC_ATTENTION_COMPRESSION):
429
+ return True
430
+
431
+ # 3. move oldest topics to bulks in chunks
432
+ if self.topics:
433
+ count = TOPICS_MERGE_COUNT if len(self.topics) >= TOPICS_MERGE_COUNT else 1
434
+ chunk = self.topics[:count]
435
bulk = Bulk(history=self)
408
- bulk.records.append(topic)
409
- if topic.summary:
410
- bulk.summary = topic.summary
411
- else:
412
- await bulk.summarize()
436
+ bulk.records.extend(chunk)
437
+ await bulk.summarize()
438
self.bulks.append(bulk)
414
- self.topics.remove(topic)
439
+ self.topics[:count] = []
440
return True
441
return False
442
python/helpers/memory_consolidation.py
+1
-1
@@ -82,7 +82,7 @@ class MemoryConsolidator:
82
83
Args:
84
new_memory: The new memory content to process
85
- area: Memory area (MAIN, FRAGMENTS, SOLUTIONS, INSTRUMENTS)
85
+ area: Memory area (MAIN, FRAGMENTS, SOLUTIONS)
86
metadata: Initial metadata for the memory
87
log_item: Optional log item for progress tracking
88