mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
parent
aee656eb9f
commit
bfc6febddc
@ -70,6 +70,8 @@ class ContextBuilder:
|
|||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
|
session_key: str | None = None,
|
||||||
|
unified_session: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
@ -96,7 +98,11 @@ class ContextBuilder:
|
|||||||
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
||||||
|
|
||||||
if include_memory_recent_history:
|
if include_memory_recent_history:
|
||||||
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
|
entries = self.memory.read_recent_history_for_prompt(
|
||||||
|
since_cursor=self.memory.get_last_dream_cursor(),
|
||||||
|
session_key=session_key,
|
||||||
|
unified_session=unified_session,
|
||||||
|
)
|
||||||
if entries:
|
if entries:
|
||||||
capped = entries[-self._MAX_RECENT_HISTORY:]
|
capped = entries[-self._MAX_RECENT_HISTORY:]
|
||||||
history_text = "\n".join(
|
history_text = "\n".join(
|
||||||
@ -196,6 +202,8 @@ class ContextBuilder:
|
|||||||
inbound_message: Any | None = None,
|
inbound_message: Any | None = None,
|
||||||
skip_runtime_lines: bool = False,
|
skip_runtime_lines: bool = False,
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
|
session_key: str | None = None,
|
||||||
|
unified_session: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
@ -232,6 +240,8 @@ class ContextBuilder:
|
|||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
workspace=root,
|
workspace=root,
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
|
session_key=session_key,
|
||||||
|
unified_session=unified_session,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
*history,
|
*history,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import time
|
|||||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
@ -314,6 +315,7 @@ class AgentLoop:
|
|||||||
get_tool_definitions=self.tools.get_definitions,
|
get_tool_definitions=self.tools.get_definitions,
|
||||||
max_completion_tokens=provider.generation.max_tokens,
|
max_completion_tokens=provider.generation.max_tokens,
|
||||||
consolidation_ratio=consolidation_ratio,
|
consolidation_ratio=consolidation_ratio,
|
||||||
|
unified_session=unified_session,
|
||||||
)
|
)
|
||||||
self.auto_compact = AutoCompact(
|
self.auto_compact = AutoCompact(
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
@ -610,6 +612,8 @@ class AgentLoop:
|
|||||||
runtime_state=self,
|
runtime_state=self,
|
||||||
inbound_message=msg,
|
inbound_message=msg,
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
|
session_key=session.key,
|
||||||
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@ -1150,6 +1154,8 @@ class AgentLoop:
|
|||||||
runtime_state=self,
|
runtime_state=self,
|
||||||
inbound_message=msg,
|
inbound_message=msg,
|
||||||
skip_runtime_lines=is_subagent,
|
skip_runtime_lines=is_subagent,
|
||||||
|
session_key=key,
|
||||||
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
t_wall = time.time()
|
t_wall = time.time()
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
@ -1163,7 +1169,9 @@ class AgentLoop:
|
|||||||
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
latency_ms = max(0, int((wall_done - t_wall) * 1000))
|
||||||
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
|
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
|
||||||
self._runtime_events().record_turn_latency(key, latency_ms)
|
self._runtime_events().record_turn_latency(key, latency_ms)
|
||||||
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
session.enforce_file_cap(
|
||||||
|
on_archive=partial(self.context.memory.raw_archive, session_key=key)
|
||||||
|
)
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
self._schedule_background(
|
self._schedule_background(
|
||||||
@ -1487,7 +1495,9 @@ class AgentLoop:
|
|||||||
ctx.turn_latency_ms,
|
ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
ctx.session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
ctx.session.enforce_file_cap(
|
||||||
|
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
||||||
|
)
|
||||||
self._schedule_background(
|
self._schedule_background(
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
|
|||||||
@ -41,6 +41,8 @@ class MemoryStore:
|
|||||||
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
||||||
|
|
||||||
_DEFAULT_MAX_HISTORY = 1000
|
_DEFAULT_MAX_HISTORY = 1000
|
||||||
|
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
||||||
|
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
||||||
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
|
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
|
||||||
_LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
|
_LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
|
||||||
_LEGACY_RAW_MESSAGE_RE = re.compile(
|
_LEGACY_RAW_MESSAGE_RE = re.compile(
|
||||||
@ -232,7 +234,13 @@ class MemoryStore:
|
|||||||
|
|
||||||
# -- history.jsonl — append-only, JSONL format ---------------------------
|
# -- history.jsonl — append-only, JSONL format ---------------------------
|
||||||
|
|
||||||
def append_history(self, entry: str, *, max_chars: int | None = None) -> int:
|
def append_history(
|
||||||
|
self,
|
||||||
|
entry: str,
|
||||||
|
*,
|
||||||
|
max_chars: int | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
|
) -> int:
|
||||||
"""Append *entry* to history.jsonl and return its auto-incrementing cursor.
|
"""Append *entry* to history.jsonl and return its auto-incrementing cursor.
|
||||||
|
|
||||||
Entries are passed through `strip_think` to drop template-level leaks
|
Entries are passed through `strip_think` to drop template-level leaks
|
||||||
@ -272,6 +280,8 @@ class MemoryStore:
|
|||||||
cursor,
|
cursor,
|
||||||
)
|
)
|
||||||
record = {"cursor": cursor, "timestamp": ts, "content": content}
|
record = {"cursor": cursor, "timestamp": ts, "content": content}
|
||||||
|
if session_key:
|
||||||
|
record["session_key"] = session_key
|
||||||
with open(self.history_file, "a", encoding="utf-8") as f:
|
with open(self.history_file, "a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
self._cursor_file.write_text(str(cursor), encoding="utf-8")
|
self._cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||||
@ -322,6 +332,36 @@ class MemoryStore:
|
|||||||
"""Return history entries with a valid cursor > *since_cursor*."""
|
"""Return history entries with a valid cursor > *since_cursor*."""
|
||||||
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
|
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_internal_history_session(cls, session_key: str | None) -> bool:
|
||||||
|
if not session_key:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
session_key in cls._INTERNAL_HISTORY_SESSION_KEYS
|
||||||
|
or session_key.startswith(cls._INTERNAL_HISTORY_SESSION_PREFIXES)
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_recent_history_for_prompt(
|
||||||
|
self,
|
||||||
|
since_cursor: int,
|
||||||
|
*,
|
||||||
|
session_key: str | None,
|
||||||
|
unified_session: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return unprocessed history entries safe to inject into a turn prompt."""
|
||||||
|
entries = self.read_unprocessed_history(since_cursor=since_cursor)
|
||||||
|
if session_key is None:
|
||||||
|
return entries
|
||||||
|
if not unified_session:
|
||||||
|
return [e for e in entries if e.get("session_key") == session_key]
|
||||||
|
|
||||||
|
return [
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if (entry_session := entry.get("session_key")) == session_key
|
||||||
|
or not self._is_internal_history_session(entry_session)
|
||||||
|
]
|
||||||
|
|
||||||
def compact_history(self) -> None:
|
def compact_history(self) -> None:
|
||||||
"""Drop oldest entries if the file exceeds *max_history_entries*."""
|
"""Drop oldest entries if the file exceeds *max_history_entries*."""
|
||||||
if self.max_history_entries <= 0:
|
if self.max_history_entries <= 0:
|
||||||
@ -489,13 +529,20 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
|
def raw_archive(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
*,
|
||||||
|
max_chars: int | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||||
formatted = truncate_text(self._format_messages(messages), limit)
|
formatted = truncate_text(self._format_messages(messages), limit)
|
||||||
self.append_history(
|
self.append_history(
|
||||||
f"[RAW] {len(messages)} messages\n"
|
f"[RAW] {len(messages)} messages\n"
|
||||||
f"{formatted}"
|
f"{formatted}",
|
||||||
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
||||||
@ -570,6 +617,7 @@ class Consolidator:
|
|||||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||||
max_completion_tokens: int = 4096,
|
max_completion_tokens: int = 4096,
|
||||||
consolidation_ratio: float = 0.5,
|
consolidation_ratio: float = 0.5,
|
||||||
|
unified_session: bool = False,
|
||||||
):
|
):
|
||||||
self.store = store
|
self.store = store
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@ -578,6 +626,7 @@ class Consolidator:
|
|||||||
self.context_window_tokens = context_window_tokens
|
self.context_window_tokens = context_window_tokens
|
||||||
self.max_completion_tokens = max_completion_tokens
|
self.max_completion_tokens = max_completion_tokens
|
||||||
self.consolidation_ratio = consolidation_ratio
|
self.consolidation_ratio = consolidation_ratio
|
||||||
|
self.unified_session = unified_session
|
||||||
self._build_messages = build_messages
|
self._build_messages = build_messages
|
||||||
self._get_tool_definitions = get_tool_definitions
|
self._get_tool_definitions = get_tool_definitions
|
||||||
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
@ -685,7 +734,7 @@ class Consolidator:
|
|||||||
len(chunk),
|
len(chunk),
|
||||||
replay_max_messages,
|
replay_max_messages,
|
||||||
)
|
)
|
||||||
summary = await self.archive(chunk)
|
summary = await self.archive(chunk, session_key=session.key)
|
||||||
session.last_consolidated = end_idx
|
session.last_consolidated = end_idx
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return summary
|
return summary
|
||||||
@ -716,6 +765,8 @@ class Consolidator:
|
|||||||
sender_id=None,
|
sender_id=None,
|
||||||
session_summary=summary,
|
session_summary=summary,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
|
session_key=session.key,
|
||||||
|
unified_session=self.unified_session,
|
||||||
)
|
)
|
||||||
return estimate_prompt_tokens_chain(
|
return estimate_prompt_tokens_chain(
|
||||||
self.provider,
|
self.provider,
|
||||||
@ -743,7 +794,12 @@ class Consolidator:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return truncate_text(text, budget * 4)
|
return truncate_text(text, budget * 4)
|
||||||
|
|
||||||
async def archive(self, messages: list[dict]) -> str | None:
|
async def archive(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
*,
|
||||||
|
session_key: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages via LLM and append to history.jsonl.
|
||||||
|
|
||||||
Returns the summary text on success, None if nothing to archive.
|
Returns the summary text on success, None if nothing to archive.
|
||||||
@ -771,11 +827,15 @@ class Consolidator:
|
|||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||||
summary = response.content or "[no summary]"
|
summary = response.content or "[no summary]"
|
||||||
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
|
self.store.append_history(
|
||||||
|
summary,
|
||||||
|
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
return summary
|
return summary
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||||
self.store.raw_archive(messages)
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def maybe_consolidate_by_tokens(
|
async def maybe_consolidate_by_tokens(
|
||||||
@ -858,7 +918,7 @@ class Consolidator:
|
|||||||
source,
|
source,
|
||||||
len(chunk),
|
len(chunk),
|
||||||
)
|
)
|
||||||
summary = await self.archive(chunk)
|
summary = await self.archive(chunk, session_key=session.key)
|
||||||
# Advance the cursor either way: on success the chunk was
|
# Advance the cursor either way: on success the chunk was
|
||||||
# summarized; on failure archive() already raw-archived it as
|
# summarized; on failure archive() already raw-archived it as
|
||||||
# a breadcrumb. Re-archiving the same chunk on the next call
|
# a breadcrumb. Re-archiving the same chunk on the next call
|
||||||
@ -930,7 +990,7 @@ class Consolidator:
|
|||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
summary: str | None = ""
|
summary: str | None = ""
|
||||||
if archive_msgs:
|
if archive_msgs:
|
||||||
summary = await self.archive(archive_msgs)
|
summary = await self.archive(archive_msgs, session_key=session_key)
|
||||||
|
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
session.metadata["_last_summary"] = {
|
session.metadata["_last_summary"] = {
|
||||||
|
|||||||
@ -63,6 +63,23 @@ class TestConsolidatorSummarize:
|
|||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
|
|
||||||
|
async def test_summarize_appends_session_key_to_history(
|
||||||
|
self,
|
||||||
|
consolidator,
|
||||||
|
mock_provider,
|
||||||
|
store,
|
||||||
|
):
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="User fixed a bug in the auth module.",
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
messages = [{"role": "user", "content": "fix the auth bug"}]
|
||||||
|
|
||||||
|
await consolidator.archive(messages, session_key="telegram:chat-1")
|
||||||
|
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert entries[0]["session_key"] == "telegram:chat-1"
|
||||||
|
|
||||||
async def test_summarize_raw_dumps_on_llm_failure(self, consolidator, mock_provider, store):
|
async def test_summarize_raw_dumps_on_llm_failure(self, consolidator, mock_provider, store):
|
||||||
"""On LLM failure, raw-dump messages to HISTORY.md."""
|
"""On LLM failure, raw-dump messages to HISTORY.md."""
|
||||||
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
||||||
@ -73,6 +90,20 @@ class TestConsolidatorSummarize:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "[RAW]" in entries[0]["content"]
|
assert "[RAW]" in entries[0]["content"]
|
||||||
|
|
||||||
|
async def test_raw_dump_fallback_appends_session_key(
|
||||||
|
self,
|
||||||
|
consolidator,
|
||||||
|
mock_provider,
|
||||||
|
store,
|
||||||
|
):
|
||||||
|
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
|
await consolidator.archive(messages, session_key="slack:chat-2")
|
||||||
|
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert entries[0]["session_key"] == "slack:chat-2"
|
||||||
|
|
||||||
async def test_summarize_skips_empty_messages(self, consolidator):
|
async def test_summarize_skips_empty_messages(self, consolidator):
|
||||||
result = await consolidator.archive([])
|
result = await consolidator.archive([])
|
||||||
assert result is None
|
assert result is None
|
||||||
@ -370,6 +401,27 @@ class TestCompactIdleSession:
|
|||||||
assert meta["text"] == "Summary of old conversation."
|
assert meta["text"] == "Summary of old conversation."
|
||||||
assert "last_active" in meta
|
assert "last_active" in meta
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_compact_writes_session_key_to_history(
|
||||||
|
self,
|
||||||
|
real_consolidator,
|
||||||
|
mock_provider,
|
||||||
|
store,
|
||||||
|
):
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="Summary of old conversation.", finish_reason="stop"
|
||||||
|
)
|
||||||
|
session = real_consolidator.sessions.get_or_create("cli:test")
|
||||||
|
for i in range(10):
|
||||||
|
session.add_message("user", f"user msg {i}")
|
||||||
|
session.add_message("assistant", f"assistant msg {i}")
|
||||||
|
real_consolidator.sessions.save(session)
|
||||||
|
|
||||||
|
await real_consolidator.compact_idle_session("cli:test", max_suffix=4)
|
||||||
|
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert entries[0]["session_key"] == "cli:test"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
|
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
|
||||||
"""Empty session with old updated_at → refreshed after call, returns ''."""
|
"""Empty session with old updated_at → refreshed after call, returns ''."""
|
||||||
@ -640,6 +692,12 @@ class TestRawArchiveTruncation:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "hello" in entries[0]["content"]
|
assert "hello" in entries[0]["content"]
|
||||||
|
|
||||||
|
def test_raw_archive_preserves_session_key(self, store):
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
store.raw_archive(messages, session_key="websocket:chat-1")
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert entries[0]["session_key"] == "websocket:chat-1"
|
||||||
|
|
||||||
def test_raw_archive_custom_max_chars(self, store):
|
def test_raw_archive_custom_max_chars(self, store):
|
||||||
"""max_chars parameter should override default limit."""
|
"""max_chars parameter should override default limit."""
|
||||||
messages = [{"role": "user", "content": "a" * 200}]
|
messages = [{"role": "user", "content": "a" * 200}]
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as datetime_module
|
||||||
import re
|
import re
|
||||||
from datetime import datetime as real_datetime
|
from datetime import datetime as real_datetime
|
||||||
from importlib.resources import files as pkg_files
|
from importlib.resources import files as pkg_files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import datetime as datetime_module
|
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
|
|
||||||
@ -156,6 +156,58 @@ def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
|||||||
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
|
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_history_injection_is_session_scoped(tmp_path) -> None:
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
builder.memory.append_history("legacy entry without session")
|
||||||
|
builder.memory.append_history("telegram history", session_key="telegram:chat-1")
|
||||||
|
builder.memory.append_history("slack history", session_key="slack:chat-2")
|
||||||
|
|
||||||
|
prompt = builder.build_system_prompt(session_key="telegram:chat-1")
|
||||||
|
|
||||||
|
assert "# Recent History" in prompt
|
||||||
|
assert "telegram history" in prompt
|
||||||
|
assert "slack history" not in prompt
|
||||||
|
assert "legacy entry without session" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_history_injection_unified_excludes_cron_internals(tmp_path) -> None:
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
builder.memory.append_history("unified user history", session_key="unified:default")
|
||||||
|
builder.memory.append_history("channel user history", session_key="telegram:chat-1")
|
||||||
|
builder.memory.append_history("cron internal history", session_key="cron:job-1")
|
||||||
|
|
||||||
|
prompt = builder.build_system_prompt(
|
||||||
|
session_key="unified:default",
|
||||||
|
unified_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "unified user history" in prompt
|
||||||
|
assert "channel user history" in prompt
|
||||||
|
assert "cron internal history" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_cron_recent_history_can_see_own_history_and_unified_context(tmp_path) -> None:
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
builder.memory.append_history("unified user history", session_key="unified:default")
|
||||||
|
builder.memory.append_history("own cron history", session_key="cron:job-1")
|
||||||
|
builder.memory.append_history("other cron history", session_key="cron:job-2")
|
||||||
|
|
||||||
|
prompt = builder.build_system_prompt(
|
||||||
|
session_key="cron:job-1",
|
||||||
|
unified_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "unified user history" in prompt
|
||||||
|
assert "own cron history" in prompt
|
||||||
|
assert "other cron history" not in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_recent_history_capped_at_max(tmp_path) -> None:
|
def test_recent_history_capped_at_max(tmp_path) -> None:
|
||||||
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
|
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
@ -201,7 +253,7 @@ def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
|
|||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
c1 = builder.memory.append_history("old conversation about Python")
|
builder.memory.append_history("old conversation about Python")
|
||||||
c2 = builder.memory.append_history("old conversation about Rust")
|
c2 = builder.memory.append_history("old conversation about Rust")
|
||||||
builder.memory.append_history("recent question about Docker")
|
builder.memory.append_history("recent question about Docker")
|
||||||
builder.memory.append_history("recent question about K8s")
|
builder.memory.append_history("recent question about K8s")
|
||||||
|
|||||||
@ -58,6 +58,12 @@ class TestHistoryWithCursor:
|
|||||||
data = json.loads(content)
|
data = json.loads(content)
|
||||||
assert data["cursor"] == 1
|
assert data["cursor"] == 1
|
||||||
|
|
||||||
|
def test_append_history_includes_session_key_when_provided(self, store):
|
||||||
|
store.append_history("event 1", session_key="telegram:chat-1")
|
||||||
|
content = store.read_file(store.history_file)
|
||||||
|
data = json.loads(content)
|
||||||
|
assert data["session_key"] == "telegram:chat-1"
|
||||||
|
|
||||||
def test_cursor_persists_across_appends(self, store):
|
def test_cursor_persists_across_appends(self, store):
|
||||||
store.append_history("event 1")
|
store.append_history("event 1")
|
||||||
store.append_history("event 2")
|
store.append_history("event 2")
|
||||||
@ -106,6 +112,54 @@ class TestHistoryWithCursor:
|
|||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
|
|
||||||
|
def test_prompt_history_filters_to_current_session(self, store):
|
||||||
|
store.append_history("legacy entry without session")
|
||||||
|
store.append_history("telegram entry", session_key="telegram:chat-1")
|
||||||
|
store.append_history("slack entry", session_key="slack:chat-2")
|
||||||
|
|
||||||
|
entries = store.read_recent_history_for_prompt(
|
||||||
|
since_cursor=0,
|
||||||
|
session_key="telegram:chat-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [e["content"] for e in entries] == ["telegram entry"]
|
||||||
|
assert [e["content"] for e in store.read_unprocessed_history(0)] == [
|
||||||
|
"legacy entry without session",
|
||||||
|
"telegram entry",
|
||||||
|
"slack entry",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_unified_prompt_history_excludes_internal_cron_sessions(self, store):
|
||||||
|
store.append_history("legacy entry without session")
|
||||||
|
store.append_history("unified entry", session_key="unified:default")
|
||||||
|
store.append_history("telegram entry", session_key="telegram:chat-1")
|
||||||
|
store.append_history("cron internal entry", session_key="cron:job-1")
|
||||||
|
|
||||||
|
entries = store.read_recent_history_for_prompt(
|
||||||
|
since_cursor=0,
|
||||||
|
session_key="unified:default",
|
||||||
|
unified_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [e["content"] for e in entries] == [
|
||||||
|
"legacy entry without session",
|
||||||
|
"unified entry",
|
||||||
|
"telegram entry",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_unified_cron_prompt_history_includes_own_cron_entry(self, store):
|
||||||
|
store.append_history("unified entry", session_key="unified:default")
|
||||||
|
store.append_history("other cron entry", session_key="cron:job-2")
|
||||||
|
store.append_history("own cron entry", session_key="cron:job-1")
|
||||||
|
|
||||||
|
entries = store.read_recent_history_for_prompt(
|
||||||
|
since_cursor=0,
|
||||||
|
session_key="cron:job-1",
|
||||||
|
unified_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [e["content"] for e in entries] == ["unified entry", "own cron entry"]
|
||||||
|
|
||||||
def test_read_unprocessed_skips_entries_without_cursor(self, store):
|
def test_read_unprocessed_skips_entries_without_cursor(self, store):
|
||||||
"""Regression: entries missing the cursor key should be silently skipped."""
|
"""Regression: entries missing the cursor key should be silently skipped."""
|
||||||
store.history_file.write_text(
|
store.history_file.write_text(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user