context_projection.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """Request-only Bot context selection; the durable Hermes transcript stays intact."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import tempfile
  7. import threading
  8. from pathlib import Path
  9. from types import MethodType
  10. def _hash(text):
  11. return hashlib.sha256(text.encode('utf-8')).hexdigest()
  12. def _atomic(path, text):
  13. fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.context-')
  14. try:
  15. with os.fdopen(fd, 'w', encoding='utf-8') as out:
  16. os.fchmod(out.fileno(), 0o600)
  17. out.write(text)
  18. os.replace(tmp, path)
  19. finally:
  20. Path(tmp).unlink(missing_ok=True)
  21. class BotContext:
  22. def __init__(self, home, session_id):
  23. self.root = Path(home) / 'cache' / 'yaoyao-bot-context' / _hash(session_id)
  24. self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
  25. self.lock = threading.RLock()
  26. self.prompts = {}
  27. path = self.root / 'prompts.json'
  28. if path.exists():
  29. data = json.loads(path.read_text(encoding='utf-8'))
  30. if not isinstance(data, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in data.items()):
  31. raise ValueError('Invalid Bot context provenance')
  32. self.prompts = data
  33. self.saved = set()
  34. self.last_stats = {}
  35. def register(self, prompt, history_text):
  36. # Only the authenticated bridge may declare the historical form. Never
  37. # parse section titles/markers out of user-authored or legacy messages.
  38. if not isinstance(prompt, str) or not isinstance(history_text, str) or not prompt or not history_text:
  39. raise ValueError('Invalid Bot context envelope')
  40. if len(prompt) > 2_000_000 or len(history_text) > 2_000_000:
  41. raise ValueError('Bot context envelope too large')
  42. key = _hash(prompt)
  43. with self.lock:
  44. if key in self.prompts and self.prompts[key] != history_text:
  45. raise ValueError('Conflicting Bot context provenance')
  46. updated = {**self.prompts, key: history_text}
  47. # The retained Hermes tail is bounded; old unknown rows fail open.
  48. updated = dict(list(updated.items())[-2048:])
  49. _atomic(self.root / 'prompts.json', json.dumps(updated, ensure_ascii=False))
  50. self.prompts = updated
  51. def _archive(self, text):
  52. key = _hash(text)
  53. if key not in self.saved:
  54. path = self.root / (key + '.txt')
  55. if not path.exists():
  56. _atomic(path, text)
  57. self.saved.add(key)
  58. return key
  59. def read(self, ref, offset=0, limit=6000):
  60. if not isinstance(ref, str) or len(ref) != 64 or any(c not in '0123456789abcdef' for c in ref):
  61. raise ValueError('Invalid history reference')
  62. if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 12000:
  63. raise ValueError('Invalid history range')
  64. text = (self.root / (ref + '.txt')).read_text(encoding='utf-8')
  65. return {'ref': ref, 'text': text[offset:offset + limit], 'totalChars': len(text),
  66. 'nextOffset': offset + limit if offset + limit < len(text) else None}
  67. def select(self, messages, incoming_message=None):
  68. with self.lock:
  69. # Leave the latest full Bot prompt in place, including its current
  70. # authorization, environment, task, facts and attachment boundaries.
  71. matched = [(i, _hash(m['content'])) for i, m in enumerate(messages)
  72. if isinstance(m, dict) and m.get('role') == 'user'
  73. and isinstance(m.get('content'), str) and _hash(m['content']) in self.prompts]
  74. latest = matched[-1][0] if matched else -1
  75. incoming = incoming_message.get('content') if isinstance(incoming_message, dict) else None
  76. incoming_key = _hash(incoming) if isinstance(incoming, str) else None
  77. if incoming_key in self.prompts and matched and matched[-1][1] != incoming_key:
  78. # Native ephemeral context may extend the current wire content.
  79. # Its original incoming message still proves that a newer full
  80. # policy is present; do not retain the previous policy as well.
  81. latest = -1
  82. changes = {}
  83. for i, key in matched:
  84. if i != latest:
  85. changes[i] = self.prompts[key]
  86. users = [i for i, m in enumerate(messages) if isinstance(m, dict) and m.get('role') == 'user']
  87. # Every result in the two newest user turns stays byte-identical.
  88. cutoff = users[-2] if len(users) >= 2 else 0
  89. seen = set()
  90. for i in range(len(messages) - 1, -1, -1):
  91. m = messages[i]
  92. if not isinstance(m, dict) or m.get('role') != 'tool' or not isinstance(m.get('content'), str):
  93. continue
  94. text = m['content']
  95. identity = (m.get('name') or m.get('tool_name'), _hash(text))
  96. duplicate = identity in seen and len(text) > 1500
  97. seen.add(identity)
  98. if i >= cutoff or (len(text) <= 8000 and not duplicate):
  99. continue
  100. ref = self._archive(text)
  101. notice = (f'\n[Earlier tool result: {len(text)} characters. '
  102. f'Full original: yaoyao_history_read(ref="{ref}", offset=0).]\n')
  103. changes[i] = notice if duplicate else text[:2500] + notice + text[-1000:]
  104. result = [{**m, 'content': changes[i]} if i in changes else m for i, m in enumerate(messages)]
  105. prompt_replacements = len([i for i, _ in matched if i != latest])
  106. self.last_stats = {'promptReplacements': prompt_replacements,
  107. 'toolReplacements': len(changes) - prompt_replacements,
  108. 'savedChars': sum(len(messages[i]['content']) - len(text) for i, text in changes.items())}
  109. return result
  110. def install(agent, home, session_id, active):
  111. existing = getattr(agent, '_yaoyao_bot_context', None)
  112. if existing is not None and getattr(agent, '_yaoyao_bot_context_engine', None) is getattr(agent, 'context_compressor', None):
  113. return existing
  114. engine = getattr(agent, 'context_compressor', None)
  115. original = getattr(engine, 'select_context', None)
  116. if not callable(original):
  117. raise ValueError('Hermes context selection is unavailable')
  118. context = existing or BotContext(home, session_id)
  119. def select_context(self, request_messages, **kwargs):
  120. selected = original(request_messages, **kwargs)
  121. source = selected if isinstance(selected, list) else request_messages
  122. return context.select(source, kwargs.get('incoming_message')) if active() else selected
  123. engine.select_context = MethodType(select_context, engine)
  124. agent._yaoyao_bot_context = context
  125. agent._yaoyao_bot_context_engine = engine
  126. return context