Files
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

173 lines
6.6 KiB
Python

"""Auto-compress a conversation when it nears the model's context budget.
When the running message list exceeds a configurable fraction (default 80%) of
the model's context window ("memory quota"), the oldest turns are summarized
into one compact note so the conversation can keep going without overflowing.
Kept Qt-free and pure so it's unit-testable and usable by any agent loop
(Cowork chat, Co4E runner, Schedule Task — all go through chat_agent.run_cowork).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from .usage_tracker import estimate_tokens
DEFAULT_LIMIT = 128_000 # tokens; used when a model isn't in the map below
DEFAULT_THRESHOLD = 0.8 # compact once usage passes 80% of the limit
_KEEP_RECENT = 6 # most-recent messages always kept verbatim
# Approximate context windows by model-name substring (longest match wins).
_MODEL_LIMITS = {
"claude": 200_000,
"opus": 200_000,
"sonnet": 200_000,
"haiku": 200_000,
"gpt-4o": 128_000,
"gpt-4.1": 1_000_000,
"o1": 200_000,
"o3": 200_000,
"gpt-4": 128_000,
"gpt-3.5": 16_000,
"gemini": 1_000_000,
}
def model_context_limit(model: str) -> int:
"""Cửa sổ ngữ cảnh (token) của một model, dò theo tiền tố tên dài nhất khớp
trong bảng; không khớp gì thì lấy ``DEFAULT_LIMIT``.
"""
m = (model or "").lower()
best = 0
limit = DEFAULT_LIMIT
for key, val in _MODEL_LIMITS.items():
if key in m and len(key) > best:
best, limit = len(key), val
return limit
def _ctx_conf(config) -> Dict[str, Any]:
"""Nhóm cấu hình ``context``; không có config thì trả dict rỗng."""
if config is None:
return {}
try:
return config.data.get("context", {}) or {}
except AttributeError:
return {}
def context_limit(config, model: str = "") -> int:
"""Configured override (context.limit_tokens > 0) else the model's window."""
conf = _ctx_conf(config)
override = int(conf.get("limit_tokens", 0) or 0)
return override if override > 0 else model_context_limit(model)
def auto_compact_enabled(config) -> bool:
"""Có tự nén lịch sử khi gần đầy ngữ cảnh không (mặc định bật)."""
conf = _ctx_conf(config)
return bool(conf.get("auto_compact", True))
def threshold(config) -> float:
"""Ngưỡng nén, tính theo tỉ lệ cửa sổ ngữ cảnh đã dùng (mặc định 0,8)."""
conf = _ctx_conf(config)
try:
t = float(conf.get("compact_threshold", DEFAULT_THRESHOLD))
except (TypeError, ValueError):
t = DEFAULT_THRESHOLD
return t if 0.1 <= t <= 0.99 else DEFAULT_THRESHOLD
def _msg_text(m: Dict[str, Any]) -> str:
"""Rút phần văn bản của một tin nhắn, kể cả khi nội dung là danh sách block
(tin nhắn có ảnh).
"""
c = m.get("content", "")
if isinstance(c, str):
return c
# tool-call/structured content: stringify defensively
return str(c)
def estimate_messages_tokens(messages: List[Dict[str, Any]]) -> int:
"""Ước lượng tổng token của cả danh sách tin nhắn."""
return sum(estimate_tokens(_msg_text(m)) for m in messages)
def should_compact(messages: List[Dict[str, Any]], limit: int,
thresh: float = DEFAULT_THRESHOLD) -> bool:
"""Đã đến lúc nén lịch sử chưa.
Không nén khi hội thoại còn quá ngắn: nén một cuộc mới vài lượt thì mất nội
dung mà chẳng tiết kiệm được bao nhiêu.
"""
if limit <= 0 or len(messages) <= _KEEP_RECENT + 2:
return False
return estimate_messages_tokens(messages) > limit * thresh
_SUMMARY_PROMPT = (
"You compress a conversation to save context. Summarize the messages below "
"into a concise but information-dense note that preserves: the user's goals, "
"key decisions, facts, file names/paths, and any state needed to continue. "
"Reply with ONLY the summary text.")
def _summarize(provider, middle: List[Dict[str, Any]], cancel=None) -> str:
"""Nhờ model tóm tắt phần giữa của hội thoại thành một đoạn ngắn."""
convo = "\n\n".join(f"[{m.get('role', '?')}] {_msg_text(m)}" for m in middle)
try:
a = provider.chat([{"role": "system", "content": _SUMMARY_PROMPT},
{"role": "user", "content": convo[:60_000]}],
tools=None, on_text=None, cancel=cancel)
text = (a.get("content") or "").strip()
if text:
return text
except Exception: # noqa: BLE001 — compaction must never break the turn
pass
# Fallback: keep the head of the oldest content so nothing is silently lost.
return convo[:4000] + ("\n…(older context truncated)" if len(convo) > 4000 else "")
def compact_messages(provider, messages: List[Dict[str, Any]], *,
keep_recent: int = _KEEP_RECENT, cancel=None) -> List[Dict[str, Any]]:
"""Return a compacted copy: system message(s) at the front (if any) + a
single summary of the middle + the last ``keep_recent`` messages verbatim.
Returns the list unchanged when there's nothing worth compacting."""
if len(messages) <= keep_recent + 2:
return messages
head_n = 1 if messages and messages[0].get("role") == "system" else 0
head = messages[:head_n]
tail = messages[-keep_recent:]
middle = messages[head_n:-keep_recent]
if not middle:
return messages
summary = _summarize(provider, middle, cancel=cancel)
note = {"role": "system",
"content": f"[Conversation summary — older messages compressed to save memory]\n{summary}"}
return list(head) + [note] + list(tail)
def maybe_compact(provider, messages: List[Dict[str, Any]], config,
emit=None, cancel=None) -> bool:
"""If auto-compact is on and usage is over threshold, compact ``messages``
IN PLACE. Returns True when a compaction happened. Safe/no-op when config
is None or the feature is off."""
if not auto_compact_enabled(config):
return False
model = getattr(provider, "model", "") or ""
limit = context_limit(config, model)
if not should_compact(messages, limit, threshold(config)):
return False
compacted = compact_messages(provider, messages, cancel=cancel)
if compacted is messages or len(compacted) >= len(messages):
return False
messages[:] = compacted
if emit:
try:
emit({"type": "notice", "level": "info",
"text": "🧹 Conversation compressed to stay within the memory limit."})
except Exception: # noqa: BLE001
pass
return True