mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 17:22:06 +03:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81aa5a4ab | ||
|
|
da96c5c6eb | ||
|
|
7ba4bc02f0 | ||
|
|
6b385a1da6 | ||
|
|
56699d4888 | ||
|
|
0274c11bfb | ||
|
|
94d62f86a0 | ||
|
|
6b07c91aa2 | ||
|
|
a3e043cf6f | ||
|
|
05100b70ad | ||
|
|
1c1f746903 | ||
|
|
138bf3c0b8 | ||
|
|
9a4781982e | ||
|
|
b47f0980f1 | ||
|
|
042f96f6ba | ||
|
|
9ecdc4533f | ||
|
|
dda67286f9 | ||
|
|
eebe3ca292 | ||
|
|
22cbfc2fb5 | ||
|
|
bd9b74e2c7 | ||
|
|
3c25f826ea | ||
|
|
195e4c281d | ||
|
|
37663ac947 | ||
|
|
e111b83af6 | ||
|
|
6d6d58d329 | ||
|
|
e69159cdae | ||
|
|
feb33e1f99 | ||
|
|
bb34b58f47 | ||
|
|
6cd7063682 | ||
|
|
d019658501 | ||
|
|
e8385d9257 | ||
|
|
5c71ef6e49 | ||
|
|
f573ecfe56 | ||
|
|
1ac1b35c84 | ||
|
|
679a07460e | ||
|
|
919e3d341e | ||
|
|
2c55934198 | ||
|
|
5afdffff51 | ||
|
|
bfe041def7 | ||
|
|
1c1b13a3a9 | ||
|
|
2c87143f77 | ||
|
|
d7df2726de |
@@ -2268,16 +2268,12 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
|
||||
|
||||
How it works:
|
||||
1. **Idle detection**: On each idle tick (~1 s), checks whether an idle-session scan is due. By default, the full scan runs at most once per minute.
|
||||
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
|
||||
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
|
||||
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
|
||||
2. **Background compaction**: Older context is summarized while the most recent messages remain available.
|
||||
3. **Session preservation**: The complete session history remains stored for later inspection and reuse.
|
||||
4. **Restart-safe resume**: The compacted context remains available after a process restart.
|
||||
|
||||
> [!NOTE]
|
||||
> Mental model: "summarize older context, keep the freshest live turns, **and overwrite the session file with the compact form.**" It is not a full `session.clear()`, but it is a write — not a soft cursor move.
|
||||
>
|
||||
> Concretely, auto compact rewrites `sessions/<key>.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file.
|
||||
>
|
||||
> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, set `idleCompactAfterMinutes` to `0` and let only the token-driven path run.
|
||||
> Auto compact shortens the context sent to the model without deleting the session's structured message history.
|
||||
|
||||
## Timezone
|
||||
|
||||
|
||||
+70
-80
@@ -30,11 +30,7 @@ from nanobot.security.workspace_access import WorkspaceScopeResolver
|
||||
from nanobot.session.keys import last_channel_from_metadata
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.session.summary import SessionSummary
|
||||
from nanobot.utils.helpers import (
|
||||
detect_image_mime,
|
||||
load_bundled_template,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.helpers import detect_image_mime, load_bundled_template
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@@ -75,14 +71,29 @@ class PersistedPromptContextResolver:
|
||||
return channel, scope.project_path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscriptInput:
|
||||
"""Raw turn inputs from which ``ContextBuilder`` assembles a transcript."""
|
||||
|
||||
history: list[dict[str, Any]]
|
||||
current_message: str | None
|
||||
media: Sequence[str] | None = None
|
||||
current_role: str = "user"
|
||||
session_summary: SessionSummary | None = None
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Number of boundary-preserving messages in the assembled transcript."""
|
||||
return 1 + len(self.history) + (self.current_message is not None)
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
|
||||
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||
|
||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||
@@ -98,9 +109,6 @@ class ContextBuilder:
|
||||
session_summary: SessionSummary | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> str:
|
||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||
root = workspace or self.workspace
|
||||
@@ -138,29 +146,6 @@ class ContextBuilder:
|
||||
if skills_summary:
|
||||
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
||||
|
||||
if include_memory_recent_history:
|
||||
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:
|
||||
capped = entries[-self._MAX_RECENT_HISTORY:]
|
||||
capped = self._without_duplicate_session_summary(
|
||||
capped,
|
||||
session_key=session_key,
|
||||
session_summary=session_summary,
|
||||
)
|
||||
if capped:
|
||||
history_text = "\n".join(
|
||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
||||
)
|
||||
history_text = truncate_text_to_tokens(
|
||||
history_text,
|
||||
self._MAX_HISTORY_TOKENS,
|
||||
)
|
||||
parts.append("# Recent History\n\n" + history_text)
|
||||
|
||||
if session_summary:
|
||||
parts.append(
|
||||
"[Archived Context Summary]\n\n"
|
||||
@@ -170,25 +155,6 @@ class ContextBuilder:
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _without_duplicate_session_summary(
|
||||
entries: list[dict[str, Any]],
|
||||
*,
|
||||
session_key: str | None,
|
||||
session_summary: SessionSummary | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop the history entry already represented by the session summary."""
|
||||
if not session_summary:
|
||||
return entries
|
||||
for index in range(len(entries) - 1, -1, -1):
|
||||
entry = entries[index]
|
||||
if (
|
||||
entry.get("session_key") == session_key
|
||||
and entry.get("content") == session_summary["text"]
|
||||
):
|
||||
return [*entries[:index], *entries[index + 1:]]
|
||||
return entries
|
||||
|
||||
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
|
||||
"""Get the core identity section."""
|
||||
root = workspace or self.workspace
|
||||
@@ -269,7 +235,7 @@ class ContextBuilder:
|
||||
def build_messages(
|
||||
self,
|
||||
history: list[dict[str, Any]],
|
||||
current_message: str,
|
||||
current_message: str | None,
|
||||
*,
|
||||
media: list[str] | None = None,
|
||||
channel: str | None = None,
|
||||
@@ -278,46 +244,70 @@ class ContextBuilder:
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
"""Compatibility wrapper for callers that need merged adjacent roles."""
|
||||
messages = self.build_transcript(
|
||||
TranscriptInput(
|
||||
history=history,
|
||||
current_message=current_message,
|
||||
media=media,
|
||||
current_role=current_role,
|
||||
session_summary=session_summary,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
),
|
||||
channel=channel,
|
||||
workspace=workspace,
|
||||
include_memory=include_memory,
|
||||
)
|
||||
if current_message is None:
|
||||
return messages
|
||||
current = messages[-1]
|
||||
if len(messages) < 2 or messages[-2].get("role") != current.get("role"):
|
||||
return messages
|
||||
|
||||
merged = dict(messages[-2])
|
||||
merged["content"] = self._merge_message_content(
|
||||
merged.get("content"),
|
||||
current.get("content"),
|
||||
)
|
||||
current_meta = current.get("_meta")
|
||||
if current.get("role") == "user" and isinstance(current_meta, dict):
|
||||
internal_meta = dict(merged.get("_meta") or {})
|
||||
internal_meta.update(cast(dict[str, Any], current_meta))
|
||||
merged["_meta"] = internal_meta
|
||||
return [*messages[:-2], merged]
|
||||
|
||||
def build_transcript(
|
||||
self,
|
||||
transcript: TranscriptInput,
|
||||
*,
|
||||
channel: str | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a model transcript while preserving the fresh-turn boundary."""
|
||||
root = workspace or self.workspace
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": self.build_system_prompt(
|
||||
channel=channel,
|
||||
session_summary=session_summary,
|
||||
session_summary=transcript.session_summary,
|
||||
workspace=root,
|
||||
include_memory=include_memory,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
session_key=session_key,
|
||||
unified_session=unified_session,
|
||||
),
|
||||
},
|
||||
*history,
|
||||
*transcript.history,
|
||||
]
|
||||
current = self.build_current_message(
|
||||
current_message,
|
||||
media=media,
|
||||
current_role=current_role,
|
||||
runtime_context_blocks=runtime_context_blocks,
|
||||
)
|
||||
if messages[-1].get("role") == current_role:
|
||||
last = dict(messages[-1])
|
||||
last["content"] = self._merge_message_content(
|
||||
last.get("content"),
|
||||
current.get("content"),
|
||||
)
|
||||
current_meta = current.get("_meta")
|
||||
if current_role == "user" and isinstance(current_meta, dict):
|
||||
internal_meta = dict(last.get("_meta") or {})
|
||||
internal_meta.update(cast(dict[str, Any], current_meta))
|
||||
last["_meta"] = internal_meta
|
||||
messages[-1] = last
|
||||
if transcript.current_message is None:
|
||||
return messages
|
||||
|
||||
current = self.build_current_message(
|
||||
transcript.current_message,
|
||||
media=list(transcript.media) if transcript.media else None,
|
||||
current_role=transcript.current_role,
|
||||
runtime_context_blocks=transcript.runtime_context_blocks,
|
||||
)
|
||||
messages.append(current)
|
||||
return messages
|
||||
|
||||
|
||||
+569
-139
@@ -1,18 +1,43 @@
|
||||
"""Model-message governance for agent runner requests.
|
||||
"""Model-message governance and compaction for agent runner requests.
|
||||
|
||||
This module owns model-facing message shaping and tool-result content normalization.
|
||||
It may return copied messages or persisted-result placeholders, but it must not
|
||||
mutate an existing session history list in place.
|
||||
This module owns model-facing message shaping, request pressure, H/delta
|
||||
compaction state, and tool-result content normalization. It may return copied
|
||||
messages or persisted-result placeholders, but it must not mutate an existing
|
||||
session history list in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.providers.base import (
|
||||
LLMResponse,
|
||||
LLMUsage,
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.providers.conversation_state import (
|
||||
ProviderConversationStateController,
|
||||
allows_conversation_message_merge,
|
||||
)
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_MESSAGE_META,
|
||||
detach_runtime_context,
|
||||
reattach_runtime_context,
|
||||
)
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.summary import (
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
SessionSummaryCheckpoint,
|
||||
)
|
||||
from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
@@ -26,13 +51,17 @@ if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
TranscriptBuilder = Callable[[TranscriptInput], list[dict[str, Any]]]
|
||||
HistoryConsolidator = Callable[
|
||||
[list[dict[str, Any]], str | None],
|
||||
Awaitable[str | None],
|
||||
]
|
||||
ProviderCompactionConsolidator = Callable[
|
||||
[ProviderConversationState, list[dict[str, Any]], str | None],
|
||||
Awaitable[str | None],
|
||||
]
|
||||
|
||||
SNIP_SAFETY_BUFFER = 1024
|
||||
MICROCOMPACT_MIN_CHARS = 500
|
||||
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
||||
COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
@@ -41,6 +70,27 @@ PLACEHOLDER_TEXTS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
class ContextWindowExceededError(RuntimeError):
|
||||
"""Raised before a locally fitted request that still exceeds its budget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_key: str | None,
|
||||
estimated_tokens: int,
|
||||
input_budget: int,
|
||||
source: str,
|
||||
) -> None:
|
||||
self.session_key = session_key
|
||||
self.estimated_tokens = estimated_tokens
|
||||
self.input_budget = input_budget
|
||||
self.source = source
|
||||
super().__init__(
|
||||
"Model input still exceeds the local context budget after request fitting "
|
||||
f"for {session_key or 'default'}: {estimated_tokens}/{input_budget} via {source}"
|
||||
)
|
||||
|
||||
|
||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||
|
||||
@@ -67,27 +117,522 @@ class ContextGovernanceConfig:
|
||||
context_window_tokens: int | None = None
|
||||
context_block_limit: int | None = None
|
||||
max_tokens: int | None = None
|
||||
inflight_start_index: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextCompactionState:
|
||||
"""Track accepted provider input H separately from the unsent delta."""
|
||||
|
||||
raw_messages: list[dict[str, Any]]
|
||||
accepted_messages: list[dict[str, Any]]
|
||||
raw_accepted_boundary: int
|
||||
active_summary: str | None
|
||||
transcript_input: TranscriptInput
|
||||
transcript_builder: TranscriptBuilder
|
||||
consolidate_history: HistoryConsolidator
|
||||
consolidate_provider_compaction: ProviderCompactionConsolidator | None
|
||||
summary_checkpoint: SessionSummaryCheckpoint | None = None
|
||||
|
||||
@classmethod
|
||||
def from_transcript(
|
||||
cls,
|
||||
transcript_input: TranscriptInput,
|
||||
transcript_builder: TranscriptBuilder,
|
||||
consolidate_history: HistoryConsolidator | None,
|
||||
consolidate_provider_compaction: ProviderCompactionConsolidator | None,
|
||||
) -> tuple[list[dict[str, Any]], ContextCompactionState | None]:
|
||||
"""Build the raw transcript and its initial H/delta boundary."""
|
||||
messages = list(transcript_builder(transcript_input))
|
||||
if consolidate_history is None:
|
||||
return messages, None
|
||||
accepted_history_boundary = 1 + len(transcript_input.history)
|
||||
return messages, cls(
|
||||
raw_messages=messages,
|
||||
accepted_messages=deepcopy(messages[:accepted_history_boundary]),
|
||||
raw_accepted_boundary=accepted_history_boundary,
|
||||
active_summary=(
|
||||
transcript_input.session_summary["text"]
|
||||
if transcript_input.session_summary is not None
|
||||
else None
|
||||
),
|
||||
transcript_input=transcript_input,
|
||||
transcript_builder=transcript_builder,
|
||||
consolidate_history=consolidate_history,
|
||||
consolidate_provider_compaction=consolidate_provider_compaction,
|
||||
)
|
||||
|
||||
def request_messages(
|
||||
self,
|
||||
raw_messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
*deepcopy(self.accepted_messages),
|
||||
*deepcopy(raw_messages[self.raw_accepted_boundary:]),
|
||||
]
|
||||
|
||||
def delta_after_accepted(
|
||||
self,
|
||||
request_messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return deepcopy(request_messages[len(self.accepted_messages):])
|
||||
|
||||
def accept_request(
|
||||
self,
|
||||
model_messages: list[dict[str, Any]],
|
||||
*,
|
||||
raw_boundary: int,
|
||||
) -> None:
|
||||
"""Advance H after the provider has received one request."""
|
||||
self.accepted_messages = deepcopy(model_messages)
|
||||
self.raw_accepted_boundary = raw_boundary
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModelRequestState:
|
||||
"""Context state shared by every provider request in one runner turn."""
|
||||
|
||||
config: ContextGovernanceConfig
|
||||
conversation: ProviderConversationStateController
|
||||
usage: LLMUsage | None = None
|
||||
messages: list[dict[str, Any]] | None = None
|
||||
tool_definitions: list[dict[str, Any]] | None = None
|
||||
compaction: ContextCompactionState | None = None
|
||||
provider_compaction_applied: bool = False
|
||||
|
||||
|
||||
class ContextGovernor:
|
||||
"""Prepare model-copy messages while preserving persisted history."""
|
||||
"""Own model-request context while preserving persisted history."""
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
if isinstance(left, str) and isinstance(right, str):
|
||||
return f"{left}\n\n{right}" if left else right
|
||||
|
||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
cast(dict[str, Any], item)
|
||||
if isinstance(item, dict)
|
||||
else {"type": "text", "text": str(item)}
|
||||
for item in cast(list[Any], value)
|
||||
]
|
||||
if value is None:
|
||||
return []
|
||||
return [{"type": "text", "text": str(value)}]
|
||||
|
||||
return _to_blocks(left) + _to_blocks(right)
|
||||
|
||||
@classmethod
|
||||
def _merge_adjacent_user_messages_for_model(
|
||||
cls,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge adjacent visible user messages only in the model-facing copy."""
|
||||
prepared: list[dict[str, Any]] = []
|
||||
for source in messages:
|
||||
injection = deepcopy(source)
|
||||
if (
|
||||
prepared
|
||||
and injection.get("role") == "user"
|
||||
and prepared[-1].get("role") == "user"
|
||||
and injection.get("content") != SUMMARY_CONTINUATION_TEXT
|
||||
and prepared[-1].get("content") != SUMMARY_CONTINUATION_TEXT
|
||||
and not is_hidden_history_message(injection)
|
||||
and not is_hidden_history_message(prepared[-1])
|
||||
and allows_conversation_message_merge(injection)
|
||||
and allows_conversation_message_merge(prepared[-1])
|
||||
):
|
||||
merged = dict(prepared[-1])
|
||||
left_meta = merged.get("_meta")
|
||||
right_meta = injection.get("_meta")
|
||||
left_meta_dict = (
|
||||
cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
|
||||
)
|
||||
right_meta_dict = (
|
||||
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
|
||||
)
|
||||
left_marker = (
|
||||
left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||
if left_meta_dict is not None
|
||||
else None
|
||||
)
|
||||
right_marker = (
|
||||
right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||
if right_meta_dict is not None
|
||||
else None
|
||||
)
|
||||
left_marker_dict = (
|
||||
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
|
||||
)
|
||||
right_marker_dict = (
|
||||
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
|
||||
)
|
||||
empty_sources: list[str] = []
|
||||
empty_blocks: list[dict[str, Any]] = []
|
||||
detached_left = (
|
||||
detach_runtime_context(merged.get("content"), left_marker_dict)
|
||||
if left_marker_dict is not None
|
||||
else (merged.get("content"), empty_sources, empty_blocks)
|
||||
)
|
||||
detached_right = (
|
||||
detach_runtime_context(injection.get("content"), right_marker_dict)
|
||||
if right_marker_dict is not None
|
||||
else (injection.get("content"), empty_sources, empty_blocks)
|
||||
)
|
||||
if detached_left is not None and detached_right is not None:
|
||||
left_content, left_sources, left_blocks = detached_left
|
||||
right_content, right_sources, right_blocks = detached_right
|
||||
merged_content = cls._merge_message_content(left_content, right_content)
|
||||
context_blocks = [*left_blocks, *right_blocks]
|
||||
if context_blocks:
|
||||
merged_content, marker = reattach_runtime_context(
|
||||
merged_content,
|
||||
[*left_sources, *right_sources],
|
||||
context_blocks,
|
||||
)
|
||||
internal_meta = (
|
||||
dict(left_meta_dict) if left_meta_dict is not None else {}
|
||||
)
|
||||
if right_meta_dict is not None:
|
||||
for key, value in right_meta_dict.items():
|
||||
internal_meta.setdefault(key, value)
|
||||
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
|
||||
merged["_meta"] = internal_meta
|
||||
merged["content"] = merged_content
|
||||
else:
|
||||
merged["content"] = cls._merge_message_content(
|
||||
merged.get("content"),
|
||||
injection.get("content"),
|
||||
)
|
||||
prepared[-1] = merged
|
||||
continue
|
||||
prepared.append(injection)
|
||||
return prepared
|
||||
|
||||
def prepare_messages_for_model(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the normalized model-facing copy of a raw transcript."""
|
||||
governed = self.prepare_for_model(config, messages)
|
||||
return self._merge_adjacent_user_messages_for_model(governed)
|
||||
|
||||
def prepare_for_model(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = self.strip_placeholder_assistant_messages(messages)
|
||||
updated = self.strip_malformed_tool_calls(updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
updated = self.apply_tool_result_budget(config, updated)
|
||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||
updated = self.snip_history(config, updated)
|
||||
return self.apply_tool_result_budget(config, updated)
|
||||
|
||||
def fit_to_budget(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fit a model-facing copy while keeping the source transcript intact."""
|
||||
updated = self.snip_history(
|
||||
config,
|
||||
messages,
|
||||
tool_definitions=tool_definitions,
|
||||
force=True,
|
||||
)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
return self.backfill_missing_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
return self.ensure_request_fits(
|
||||
config,
|
||||
updated,
|
||||
tool_definitions=tool_definitions,
|
||||
)
|
||||
|
||||
def ensure_request_fits(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Validate an exact model request without dropping any messages."""
|
||||
if not config.context_window_tokens:
|
||||
return messages
|
||||
budget = self.input_budget(config)
|
||||
estimated, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tool_definitions,
|
||||
)
|
||||
if budget > 0 and estimated <= budget:
|
||||
return messages
|
||||
raise ContextWindowExceededError(
|
||||
session_key=config.session_key,
|
||||
estimated_tokens=estimated,
|
||||
input_budget=budget,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def request_pressure(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
usage: LLMUsage | None,
|
||||
*,
|
||||
usage_matches_messages: bool,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
request_context_tokens: int | None = None,
|
||||
) -> tuple[int, str] | None:
|
||||
"""Return the authoritative measurement when a request is pressured."""
|
||||
if not config.context_window_tokens:
|
||||
return None
|
||||
budget = self.input_budget(config)
|
||||
if request_context_tokens is not None:
|
||||
measured = request_context_tokens
|
||||
source = "resumed provider state plus pending messages"
|
||||
elif (
|
||||
usage_matches_messages
|
||||
and usage is not None
|
||||
and usage.context_tokens is not None
|
||||
):
|
||||
measured = usage.context_tokens
|
||||
source = "matching provider usage"
|
||||
else:
|
||||
measured, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tool_definitions,
|
||||
)
|
||||
if budget > 0 and measured < budget:
|
||||
return None
|
||||
return measured, source
|
||||
|
||||
def fit_request(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
usage: LLMUsage | None,
|
||||
*,
|
||||
usage_matches_messages: bool,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
request_context_tokens: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Fit the request when its measured or estimated input is pressured."""
|
||||
pressure = self.request_pressure(
|
||||
config,
|
||||
messages,
|
||||
usage,
|
||||
usage_matches_messages=usage_matches_messages,
|
||||
tool_definitions=tool_definitions,
|
||||
request_context_tokens=request_context_tokens,
|
||||
)
|
||||
if pressure is None:
|
||||
return messages, False
|
||||
return self.fit_to_budget(
|
||||
config,
|
||||
messages,
|
||||
tool_definitions=tool_definitions,
|
||||
), True
|
||||
|
||||
@staticmethod
|
||||
def _summary_transcript(
|
||||
compaction: ContextCompactionState,
|
||||
summary: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rebuild only the stable system prefix around a replacement summary."""
|
||||
return compaction.transcript_builder(
|
||||
replace(
|
||||
compaction.transcript_input,
|
||||
history=[],
|
||||
current_message=None,
|
||||
media=None,
|
||||
session_summary={
|
||||
"text": summary,
|
||||
"last_active": datetime.now().astimezone().isoformat(),
|
||||
},
|
||||
runtime_context_blocks=None,
|
||||
)
|
||||
)
|
||||
|
||||
async def summarize_provider_compaction(
|
||||
self,
|
||||
state: ModelRequestState,
|
||||
response: LLMResponse,
|
||||
*,
|
||||
current_request_boundary: int | None,
|
||||
) -> None:
|
||||
"""Materialize the exact input replaced by provider-native compaction."""
|
||||
compaction = state.compaction
|
||||
if (
|
||||
not response.provider_compaction_applied
|
||||
or response.provider_compaction_state is None
|
||||
or compaction is None
|
||||
or compaction.consolidate_provider_compaction is None
|
||||
):
|
||||
return
|
||||
|
||||
if response.provider_compaction_scope == "prior_context":
|
||||
accepted_messages = compaction.accepted_messages
|
||||
transcript_boundary = compaction.raw_accepted_boundary
|
||||
elif (
|
||||
response.provider_compaction_scope == "current_request"
|
||||
and state.messages is not None
|
||||
and current_request_boundary is not None
|
||||
):
|
||||
accepted_messages = state.messages
|
||||
transcript_boundary = current_request_boundary
|
||||
else:
|
||||
logger.warning(
|
||||
"Ignoring provider compaction with missing request-boundary scope for {}",
|
||||
state.config.session_key or "default",
|
||||
)
|
||||
return
|
||||
|
||||
summary = await compaction.consolidate_provider_compaction(
|
||||
response.provider_compaction_state,
|
||||
deepcopy(accepted_messages),
|
||||
compaction.active_summary,
|
||||
)
|
||||
if not summary:
|
||||
return
|
||||
compaction.active_summary = summary
|
||||
compaction.summary_checkpoint = SessionSummaryCheckpoint(
|
||||
summary=summary,
|
||||
transcript_boundary=transcript_boundary,
|
||||
)
|
||||
|
||||
async def _compact_request_history(
|
||||
self,
|
||||
state: ModelRequestState,
|
||||
compaction: ContextCompactionState,
|
||||
messages: list[dict[str, Any]],
|
||||
pressure: tuple[int, str],
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Replace accepted history H with a checkpoint while preserving delta."""
|
||||
delta_messages = compaction.delta_after_accepted(messages)
|
||||
consolidation_prefix = self.prepare_messages_for_model(
|
||||
state.config,
|
||||
compaction.accepted_messages,
|
||||
)
|
||||
summary = await compaction.consolidate_history(
|
||||
deepcopy(consolidation_prefix),
|
||||
compaction.active_summary,
|
||||
)
|
||||
if not summary:
|
||||
measured, source = pressure
|
||||
raise ContextWindowExceededError(
|
||||
session_key=state.config.session_key,
|
||||
estimated_tokens=measured,
|
||||
input_budget=self.input_budget(state.config),
|
||||
source=source,
|
||||
)
|
||||
|
||||
compaction.active_summary = summary
|
||||
prepared = self.prepare_messages_for_model(
|
||||
state.config,
|
||||
[
|
||||
*self._summary_transcript(compaction, summary),
|
||||
{"role": "user", "content": SUMMARY_CONTINUATION_TEXT},
|
||||
*delta_messages,
|
||||
],
|
||||
)
|
||||
# Responses-style state is append-only. Replacing H with a
|
||||
# checkpoint requires a fresh request; a successful response may
|
||||
# establish a new provider-owned state at the rewritten boundary.
|
||||
state.conversation.replace_transcript(compaction.raw_messages)
|
||||
state.usage = None
|
||||
prepared = self.ensure_request_fits(
|
||||
state.config,
|
||||
prepared,
|
||||
tool_definitions=tool_definitions,
|
||||
)
|
||||
compaction.summary_checkpoint = SessionSummaryCheckpoint(
|
||||
summary=summary,
|
||||
transcript_boundary=compaction.raw_accepted_boundary,
|
||||
)
|
||||
return prepared
|
||||
|
||||
async def prepare_request(
|
||||
self,
|
||||
state: ModelRequestState,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
transcript: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], ProviderCallContext | None]:
|
||||
"""Prepare, compact or fit, and record the exact provider payload."""
|
||||
prepared = self.prepare_messages_for_model(state.config, messages)
|
||||
model_messages: list[dict[str, Any]] | None = prepared
|
||||
supplemental_messages: list[dict[str, Any]] | None = None
|
||||
request_context_tokens = None
|
||||
if transcript is not None:
|
||||
if tool_definitions is None:
|
||||
model_messages = None
|
||||
supplemental_messages = [prepared[-1]]
|
||||
request_context_tokens = state.conversation.estimate_request_context_tokens(
|
||||
transcript,
|
||||
model_messages=model_messages,
|
||||
supplemental_messages=supplemental_messages,
|
||||
tool_definitions=tool_definitions,
|
||||
)
|
||||
usage_matches_messages = (
|
||||
state.messages is not None
|
||||
and prepared == state.messages
|
||||
and tool_definitions == state.tool_definitions
|
||||
)
|
||||
request_was_fitted = False
|
||||
compaction = state.compaction
|
||||
if compaction is None:
|
||||
prepared, request_was_fitted = self.fit_request(
|
||||
state.config,
|
||||
prepared,
|
||||
state.usage,
|
||||
usage_matches_messages=usage_matches_messages,
|
||||
tool_definitions=tool_definitions,
|
||||
request_context_tokens=request_context_tokens,
|
||||
)
|
||||
else:
|
||||
pressure = self.request_pressure(
|
||||
state.config,
|
||||
prepared,
|
||||
state.usage,
|
||||
usage_matches_messages=usage_matches_messages,
|
||||
tool_definitions=tool_definitions,
|
||||
request_context_tokens=request_context_tokens,
|
||||
)
|
||||
if pressure is not None:
|
||||
prepared = await self._compact_request_history(
|
||||
state,
|
||||
compaction,
|
||||
messages,
|
||||
pressure,
|
||||
tool_definitions=tool_definitions,
|
||||
)
|
||||
model_messages = prepared
|
||||
supplemental_messages = None
|
||||
provider_context = (
|
||||
state.conversation.prepare_request(
|
||||
transcript,
|
||||
context_window_tokens=state.config.context_window_tokens,
|
||||
model_messages=model_messages,
|
||||
supplemental_messages=supplemental_messages,
|
||||
resume_state=not request_was_fitted,
|
||||
)
|
||||
if transcript is not None
|
||||
else state.conversation.independent_request_context(
|
||||
context_window_tokens=state.config.context_window_tokens,
|
||||
)
|
||||
)
|
||||
state.messages = deepcopy(prepared)
|
||||
state.tool_definitions = deepcopy(tool_definitions)
|
||||
return prepared, provider_context
|
||||
|
||||
@staticmethod
|
||||
def input_budget(config: ContextGovernanceConfig) -> int:
|
||||
@@ -326,71 +871,13 @@ class ContextGovernor:
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def compact_inflight_overflow(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compact in-flight tool results only when the request would overflow."""
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return updated
|
||||
|
||||
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
|
||||
candidates = self._inflight_compaction_candidates(
|
||||
config,
|
||||
updated,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
if not candidates:
|
||||
return updated
|
||||
|
||||
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
|
||||
is_newest_candidate = candidate_idx == len(candidates) - 1
|
||||
if is_newest_candidate and estimate <= budget:
|
||||
break
|
||||
if tool_call_id in compacted_tool_call_ids:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
compacted_tool_call_ids.add(tool_call_id)
|
||||
self._compact_tool_result_at(updated, idx)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
|
||||
config.session_key or "default",
|
||||
estimate,
|
||||
budget,
|
||||
target,
|
||||
source,
|
||||
len(compacted_tool_call_ids),
|
||||
)
|
||||
return updated
|
||||
|
||||
def snip_history(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
force: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not config.context_window_tokens:
|
||||
return messages
|
||||
@@ -399,12 +886,12 @@ class ContextGovernor:
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
if not force:
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tools,
|
||||
tool_definitions,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
@@ -419,7 +906,7 @@ class ContextGovernor:
|
||||
config.provider,
|
||||
config.model,
|
||||
system_messages,
|
||||
tools,
|
||||
tool_definitions,
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
@@ -434,16 +921,6 @@ class ContextGovernor:
|
||||
|
||||
return system_messages + self._legal_history_tail(kept, non_system)
|
||||
|
||||
@staticmethod
|
||||
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
|
||||
name = message.get("name", "tool")
|
||||
return (
|
||||
f"Error: The previous {name} result was compacted to fit context because it was too "
|
||||
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
|
||||
"range, or result limit, use another tool, or tell the user the task cannot fit in "
|
||||
"the available context."
|
||||
)
|
||||
|
||||
def _legal_history_tail(
|
||||
self,
|
||||
kept: list[dict[str, Any]],
|
||||
@@ -462,50 +939,3 @@ class ContextGovernor:
|
||||
if messages[idx].get("role") == "user":
|
||||
return messages[idx:]
|
||||
return []
|
||||
|
||||
def _apply_recorded_compactions(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not compacted_tool_call_ids:
|
||||
return messages
|
||||
updated = messages
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||
continue
|
||||
compaction_message = self._tool_result_compaction_message(msg)
|
||||
if msg.get("content") == compaction_message:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = compaction_message
|
||||
return updated
|
||||
|
||||
def _inflight_compaction_candidates(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[tuple[int, str]]:
|
||||
compactable: list[tuple[int, str]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if idx < config.inflight_start_index:
|
||||
continue
|
||||
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
compactable.append((idx, str(tool_call_id)))
|
||||
|
||||
return compactable
|
||||
|
||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
||||
|
||||
+158
-57
@@ -13,7 +13,9 @@ import weakref
|
||||
from collections.abc import Coroutine, Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, cast
|
||||
|
||||
@@ -23,7 +25,7 @@ from nanobot.agent import context as agent_context
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.context import ContextBuilder, PersistedPromptContextResolver
|
||||
from nanobot.agent.context import ContextBuilder, PersistedPromptContextResolver, TranscriptInput
|
||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
||||
from nanobot.agent.memory import Consolidator
|
||||
@@ -92,7 +94,11 @@ from nanobot.session.recovery import (
|
||||
restore_pending_interruption,
|
||||
restore_runtime_checkpoint,
|
||||
)
|
||||
from nanobot.session.summary import SessionSummary
|
||||
from nanobot.session.summary import (
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
SessionSummary,
|
||||
SessionSummaryCheckpoint,
|
||||
)
|
||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||
from nanobot.utils.cancellation import task_is_cancelling
|
||||
from nanobot.utils.document import reference_non_image_attachments
|
||||
@@ -135,7 +141,7 @@ class TurnContext:
|
||||
session: Session | None = None
|
||||
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
transcript_input: TranscriptInput | None = None
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
request_context: RequestContext | None = None
|
||||
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||
@@ -160,6 +166,8 @@ class TurnContext:
|
||||
|
||||
pending_queue: asyncio.Queue[InboundMessage] | None = None
|
||||
pending_summary: SessionSummary | None = None
|
||||
summary_checkpoint: SessionSummaryCheckpoint | None = None
|
||||
provider_compaction_applied: bool = False
|
||||
|
||||
ephemeral: bool = False
|
||||
run_extra_hooks_for_ephemeral: bool = False
|
||||
@@ -443,7 +451,6 @@ class AgentLoop:
|
||||
workspace_scopes=self.workspace_scopes,
|
||||
unified_session=unified_session,
|
||||
),
|
||||
unified_session=unified_session,
|
||||
)
|
||||
self.auto_compact = AutoCompact(
|
||||
sessions=self.sessions,
|
||||
@@ -723,22 +730,15 @@ class AgentLoop:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_initial_messages(self, ctx: TurnContext) -> list[dict[str, Any]]:
|
||||
"""Build the initial message list for the LLM turn."""
|
||||
def _build_transcript_input(self, ctx: TurnContext) -> TranscriptInput:
|
||||
"""Capture the persisted history and fresh input as separate transcript parts."""
|
||||
assert ctx.session is not None
|
||||
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||
return self.context.build_messages(
|
||||
return TranscriptInput(
|
||||
history=ctx.history,
|
||||
current_message=ctx.msg.content,
|
||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||
channel=ctx.delivery.route.channel,
|
||||
session_summary=ctx.pending_summary,
|
||||
workspace=scope.project_path,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
include_memory=ctx.session.policy.persist,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
)
|
||||
|
||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||
@@ -859,6 +859,22 @@ class AgentLoop:
|
||||
metadata={**metadata, "render_as": "text"},
|
||||
)
|
||||
|
||||
def _track_active_task(self, key: str, task: asyncio.Task[Any]) -> None:
|
||||
"""Track active session work until its task group becomes empty."""
|
||||
tasks = self._active_tasks.setdefault(key, set())
|
||||
tasks.add(task)
|
||||
task.add_done_callback(partial(self._active_task_done, key, tasks))
|
||||
|
||||
def _active_task_done(
|
||||
self,
|
||||
key: str,
|
||||
tasks: set[asyncio.Task[Any]],
|
||||
task: asyncio.Task[Any],
|
||||
) -> None:
|
||||
tasks.discard(task)
|
||||
if not tasks and self._active_tasks.get(key) is tasks:
|
||||
self._active_tasks.pop(key, None)
|
||||
|
||||
async def _cancel_active_tasks(self, key: str) -> int:
|
||||
"""Cancel and await all active work for *key*.
|
||||
|
||||
@@ -914,22 +930,9 @@ class AgentLoop:
|
||||
return
|
||||
remember_last_channel(session.metadata, msg.channel, msg.chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _replay_token_budget(runtime: LLMRuntime) -> int:
|
||||
"""Derive a token budget for session history replay from the context window."""
|
||||
if runtime.context_window_tokens <= 0:
|
||||
return 0
|
||||
max_output = runtime.generation.max_tokens
|
||||
try:
|
||||
reserved_output = int(max_output)
|
||||
except (TypeError, ValueError):
|
||||
reserved_output = 4096
|
||||
budget = runtime.context_window_tokens - max(1, reserved_output) - 1024
|
||||
return budget if budget > 0 else max(128, runtime.context_window_tokens // 2)
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
initial_messages: list[dict[str, Any]],
|
||||
transcript_input: TranscriptInput,
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
@@ -1110,6 +1113,12 @@ class AgentLoop:
|
||||
message_metadata=request_metadata,
|
||||
session_metadata=session.metadata if session is not None else None,
|
||||
)
|
||||
transcript_builder = partial(
|
||||
self.context.build_transcript,
|
||||
channel=request_ctx.channel,
|
||||
workspace=effective_scope.project_path,
|
||||
include_memory=session.policy.persist if session is not None else True,
|
||||
)
|
||||
if request_context is None:
|
||||
request_ctx = dataclasses.replace(
|
||||
request_ctx,
|
||||
@@ -1156,11 +1165,13 @@ class AgentLoop:
|
||||
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
|
||||
))
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
initial_messages=None,
|
||||
tools=effective_tools,
|
||||
runtime=runtime,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
transcript_input=transcript_input,
|
||||
transcript_builder=transcript_builder,
|
||||
hook=hook,
|
||||
concurrent_tools=True,
|
||||
workspace=effective_scope.project_path,
|
||||
@@ -1169,6 +1180,26 @@ class AgentLoop:
|
||||
provider_retry_mode=self.provider_retry_mode,
|
||||
retry_wait_callback=on_retry_wait,
|
||||
checkpoint_callback=_checkpoint,
|
||||
consolidate_history=(
|
||||
partial(
|
||||
self.consolidator.summarize_transcript,
|
||||
runtime=runtime,
|
||||
session_key=session.key,
|
||||
tools=effective_tools.get_definitions(),
|
||||
)
|
||||
if session is not None and not ephemeral
|
||||
else None
|
||||
),
|
||||
consolidate_provider_compaction=(
|
||||
partial(
|
||||
self.consolidator.summarize_provider_compaction,
|
||||
runtime=runtime,
|
||||
session_key=session.key,
|
||||
tools=effective_tools.get_definitions(),
|
||||
)
|
||||
if session is not None and not ephemeral
|
||||
else None
|
||||
),
|
||||
injection_callback=_drain_pending,
|
||||
terminal_injection_callback=_wait_for_pending,
|
||||
# Sustained goals may legitimately exceed NANOBOT_LLM_TIMEOUT_S; idle stall
|
||||
@@ -1349,12 +1380,7 @@ class AgentLoop:
|
||||
# Compute the effective session key before dispatching
|
||||
# This ensures /stop command can find tasks correctly when unified session is enabled
|
||||
task = asyncio.create_task(self._dispatch(msg))
|
||||
active_tasks: set[asyncio.Task[Any]] = self._active_tasks.setdefault(
|
||||
effective_key,
|
||||
set(),
|
||||
)
|
||||
active_tasks.add(task)
|
||||
task.add_done_callback(active_tasks.discard)
|
||||
self._track_active_task(effective_key, task)
|
||||
finally:
|
||||
await self.aclose()
|
||||
|
||||
@@ -1874,17 +1900,14 @@ class AgentLoop:
|
||||
if ctx.on_runtime_admitted is not None:
|
||||
await ctx.on_runtime_admitted(runtime)
|
||||
if not ctx.ephemeral:
|
||||
await self.consolidator.maybe_consolidate_by_tokens(
|
||||
ctx.session, ctx.pending_summary = self.auto_compact.prepare_session(
|
||||
session,
|
||||
runtime=runtime,
|
||||
ctx.session_key,
|
||||
)
|
||||
session = ctx.require_session()
|
||||
is_subagent = ctx.kind is TurnKind.SYSTEM and ctx.msg.sender_id == "subagent"
|
||||
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_tokens": self._replay_token_budget(runtime),
|
||||
"extend_to_user": is_subagent,
|
||||
}
|
||||
ctx.history = session.get_history(**_hist_kwargs)
|
||||
ctx.history = session.get_history(extend_to_user=is_subagent)
|
||||
stored_state = session.provider_state
|
||||
subagent_followup_persisted = False
|
||||
if is_subagent:
|
||||
@@ -1968,7 +1991,7 @@ class AgentLoop:
|
||||
# Upgrade the replay-safe baseline to the resumable state before
|
||||
# prompt assembly and the first model checkpoint.
|
||||
self.sessions.save(session)
|
||||
ctx.initial_messages = self._build_initial_messages(ctx)
|
||||
ctx.transcript_input = self._build_transcript_input(ctx)
|
||||
|
||||
if ctx.on_progress is None:
|
||||
ctx.on_progress = ctx.delivery.progress_callback()
|
||||
@@ -1980,9 +2003,10 @@ class AgentLoop:
|
||||
if ctx.visible_run_started_at is None:
|
||||
ctx.visible_run_started_at = time.time()
|
||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||
assert ctx.transcript_input is not None
|
||||
with capture_message_deliveries() as message_sends:
|
||||
result = await self._run_agent_loop(
|
||||
ctx.initial_messages,
|
||||
ctx.transcript_input,
|
||||
runtime=runtime,
|
||||
on_progress=ctx.on_progress,
|
||||
on_stream=ctx.on_stream,
|
||||
@@ -2001,6 +2025,8 @@ class AgentLoop:
|
||||
)
|
||||
ctx.final_content = result.final_content
|
||||
ctx.all_messages = result.messages
|
||||
ctx.summary_checkpoint = result.summary_checkpoint
|
||||
ctx.provider_compaction_applied = result.provider_compaction_applied
|
||||
ctx.stop_reason = result.stop_reason
|
||||
if (
|
||||
ctx.kind is TurnKind.USER
|
||||
@@ -2014,7 +2040,6 @@ class AgentLoop:
|
||||
await turn_continuation.maybe_continue_turn(ctx)
|
||||
|
||||
async def _persist_turn(self, ctx: TurnContext) -> None:
|
||||
runtime = ctx.require_runtime()
|
||||
session = ctx.require_session()
|
||||
turn_continuation.prepare_save_boundary(ctx)
|
||||
|
||||
@@ -2040,15 +2065,18 @@ class AgentLoop:
|
||||
self._save_turn(
|
||||
session, ctx.all_messages, ctx.save_skip,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
summary_checkpoint=ctx.summary_checkpoint,
|
||||
input_persisted_early=ctx.input_persisted_early,
|
||||
)
|
||||
if (
|
||||
not ctx.ephemeral
|
||||
and ctx.provider_compaction_applied
|
||||
and ctx.summary_checkpoint is not None
|
||||
):
|
||||
# The next request must rebuild from the portable checkpoint;
|
||||
# the opaque continuation predates that transcript rewrite.
|
||||
session.provider_state = None
|
||||
ctx.delivery.record_latency(ctx.turn_latency_ms)
|
||||
if not ctx.ephemeral:
|
||||
self.schedule_background(
|
||||
self.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
)
|
||||
self._clear_pending_user_turn(session)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
@@ -2122,6 +2150,55 @@ class AgentLoop:
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def _insert_summary_checkpoint(
|
||||
session: Session,
|
||||
checkpoint: SessionSummaryCheckpoint,
|
||||
*,
|
||||
insert_at: int | None = None,
|
||||
) -> None:
|
||||
"""Commit a replacement summary and its hidden transcript boundary."""
|
||||
hint = {
|
||||
"role": "user",
|
||||
"content": SUMMARY_CONTINUATION_TEXT,
|
||||
HIDDEN_HISTORY_META: True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
if insert_at is None:
|
||||
session.messages.append(hint)
|
||||
checkpoint_session_index = len(session.messages) - 1
|
||||
else:
|
||||
session.messages.insert(insert_at, hint)
|
||||
checkpoint_session_index = insert_at
|
||||
session.metadata["_last_summary"] = {
|
||||
"text": checkpoint.summary,
|
||||
"last_active": session.updated_at.isoformat(),
|
||||
}
|
||||
session.last_archived = checkpoint_session_index
|
||||
|
||||
@staticmethod
|
||||
def _validated_checkpoint_boundary(
|
||||
checkpoint: SessionSummaryCheckpoint | None,
|
||||
*,
|
||||
skip: int,
|
||||
message_count: int,
|
||||
session_key: str,
|
||||
) -> int | None:
|
||||
"""Return a checkpoint boundary only when it belongs to this turn."""
|
||||
if checkpoint is None:
|
||||
return None
|
||||
boundary = checkpoint.transcript_boundary
|
||||
if skip - 1 <= boundary <= message_count:
|
||||
return boundary
|
||||
logger.warning(
|
||||
"Ignoring invalid summary boundary {} outside [{}, {}] for {}",
|
||||
boundary,
|
||||
skip - 1,
|
||||
message_count,
|
||||
session_key,
|
||||
)
|
||||
return None
|
||||
|
||||
def _save_turn(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -2129,10 +2206,10 @@ class AgentLoop:
|
||||
skip: int,
|
||||
*,
|
||||
turn_latency_ms: int | None = None,
|
||||
summary_checkpoint: SessionSummaryCheckpoint | None = None,
|
||||
input_persisted_early: bool = False,
|
||||
) -> None:
|
||||
"""Save new-turn messages into session, truncating large tool results."""
|
||||
from datetime import datetime
|
||||
|
||||
"""Commit new-turn messages and an optional summary boundary."""
|
||||
declared_tool_call_ids = {
|
||||
str(tc["id"])
|
||||
for m in session.messages
|
||||
@@ -2149,8 +2226,30 @@ class AgentLoop:
|
||||
}
|
||||
last_assistant_idx: int | None = None
|
||||
saved_followup_ids: set[str] = set()
|
||||
for m in messages[skip:]:
|
||||
entry = dict(m)
|
||||
checkpoint_boundary = self._validated_checkpoint_boundary(
|
||||
summary_checkpoint,
|
||||
skip=skip,
|
||||
message_count=len(messages),
|
||||
session_key=session.key,
|
||||
)
|
||||
|
||||
# The trigger input may already be the session tail while still being
|
||||
# the first message after the replacement checkpoint.
|
||||
if summary_checkpoint is not None and checkpoint_boundary == skip - 1:
|
||||
insert_at = len(session.messages) - (1 if input_persisted_early else 0)
|
||||
self._insert_summary_checkpoint(
|
||||
session,
|
||||
summary_checkpoint,
|
||||
insert_at=insert_at,
|
||||
)
|
||||
|
||||
for message_index, message in enumerate(messages[skip:], start=skip):
|
||||
# Insert against the raw transcript index before filtering the
|
||||
# message so persistence cleanup cannot shift the H/Δ boundary.
|
||||
if summary_checkpoint is not None and checkpoint_boundary == message_index:
|
||||
self._insert_summary_checkpoint(session, summary_checkpoint)
|
||||
|
||||
entry = dict(message)
|
||||
followup_id_value = cast(object, entry.pop(PENDING_FOLLOWUP_ID_KEY, None))
|
||||
followup_ids = (
|
||||
[followup_id_value]
|
||||
@@ -2229,6 +2328,8 @@ class AgentLoop:
|
||||
for tc in (cast(dict[str, Any], tc_value),)
|
||||
if tc.get("id")
|
||||
)
|
||||
if summary_checkpoint is not None and checkpoint_boundary == len(messages):
|
||||
self._insert_summary_checkpoint(session, summary_checkpoint)
|
||||
if turn_latency_ms is not None and last_assistant_idx is not None:
|
||||
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
||||
if saved_followup_ids:
|
||||
|
||||
+287
-283
@@ -1,4 +1,4 @@
|
||||
"""Memory storage, transcript archiving, and legacy consolidation coordination."""
|
||||
"""Memory storage, transcript archiving, and session checkpoint consolidation."""
|
||||
|
||||
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
|
||||
# runtime; static analyzers cannot observe that it clears ``parameters`` from
|
||||
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.llm_usage.context import llm_usage_source
|
||||
from nanobot.providers.base import ProviderCallContext, ProviderConversationState
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.session.manager import (
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
@@ -35,6 +36,7 @@ from nanobot.utils.helpers import (
|
||||
estimate_prompt_tokens_chain,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.workspace_prompts import (
|
||||
@@ -61,12 +63,6 @@ class MemoryStore:
|
||||
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never
|
||||
# appears as a durable-memory edit in the audit record.
|
||||
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
|
||||
# Per-file cap when embedding current contents into the Dream prompt. The
|
||||
# durable files are tiny in practice (~5 KB total), but a runaway file must
|
||||
# not unbounded the prompt.
|
||||
_DREAM_FILE_EMBED_CAP = 8000
|
||||
_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_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
|
||||
_LEGACY_RAW_MESSAGE_RE = re.compile(
|
||||
@@ -260,6 +256,29 @@ class MemoryStore:
|
||||
|
||||
# -- history.jsonl — append-only, JSONL format ---------------------------
|
||||
|
||||
def _normalize_history_entry(
|
||||
self,
|
||||
entry: str,
|
||||
*,
|
||||
max_chars: int | None = None,
|
||||
) -> str:
|
||||
"""Return the exact bounded, model-safe text accepted by the journal."""
|
||||
limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
|
||||
raw = entry.rstrip()
|
||||
content = strip_think(raw)
|
||||
if len(content) > limit:
|
||||
if not self._oversize_logged:
|
||||
self._oversize_logged = True
|
||||
logger.warning(
|
||||
"history entry exceeds {} chars ({}); truncating. "
|
||||
"Usually means a caller forgot its own cap; "
|
||||
"further occurrences suppressed.",
|
||||
limit,
|
||||
len(content),
|
||||
)
|
||||
content = truncate_text(content, limit)
|
||||
return content
|
||||
|
||||
def append_history(
|
||||
self,
|
||||
entry: str,
|
||||
@@ -274,27 +293,16 @@ class MemoryStore:
|
||||
persisted. If the cleaned content is empty but the raw entry wasn't,
|
||||
the record is persisted with an empty string rather than falling back
|
||||
to the raw leak — otherwise `strip_think`'s guarantees would be
|
||||
undone by history replay / consolidation downstream.
|
||||
undone when Dream consumes the journal entry.
|
||||
|
||||
A defensive cap (*max_chars*, default ``_HISTORY_ENTRY_HARD_CAP``) is
|
||||
applied as a final safety net: individual callers should cap their own
|
||||
content more tightly; this default only exists to catch unintentional
|
||||
large writes (e.g. an LLM echoing its input back as a "summary").
|
||||
"""
|
||||
limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
raw = entry.rstrip()
|
||||
if len(raw) > limit:
|
||||
if not self._oversize_logged:
|
||||
self._oversize_logged = True
|
||||
logger.warning(
|
||||
"history entry exceeds {} chars ({}); truncating. "
|
||||
"Usually means a caller forgot its own cap; "
|
||||
"further occurrences suppressed.",
|
||||
limit, len(raw),
|
||||
)
|
||||
raw = truncate_text(raw, limit)
|
||||
content = strip_think(raw)
|
||||
content = self._normalize_history_entry(entry, max_chars=max_chars)
|
||||
# Cursor allocation and the append must be atomic: concurrent writers
|
||||
# could otherwise read the same current cursor and emit duplicates.
|
||||
with self._append_lock:
|
||||
@@ -302,7 +310,7 @@ class MemoryStore:
|
||||
if raw and not content:
|
||||
logger.debug(
|
||||
"history entry {} stripped to empty (likely template leak); "
|
||||
"persisting empty content to avoid re-polluting context",
|
||||
"persisting empty content to avoid re-polluting Dream input",
|
||||
cursor,
|
||||
)
|
||||
record = {"cursor": cursor, "timestamp": ts, "content": content}
|
||||
@@ -392,36 +400,6 @@ class MemoryStore:
|
||||
"""Return history entries with a valid cursor > *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:
|
||||
"""Drop oldest processed entries without discarding pending Dream input."""
|
||||
if self.max_history_entries <= 0:
|
||||
@@ -568,9 +546,7 @@ class MemoryStore:
|
||||
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
||||
|
||||
The current contents of the durable memory files (SOUL.md, USER.md,
|
||||
memory/MEMORY.md) are embedded so the model edits the real files rather
|
||||
than a stale mental model — eliminating a class of failed/out-of-bounds
|
||||
edits that previously produced hallucinated audit records.
|
||||
memory/MEMORY.md) reach Dream through the normal agent system context.
|
||||
"""
|
||||
last_cursor = self.get_last_dream_cursor()
|
||||
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
||||
@@ -583,35 +559,9 @@ class MemoryStore:
|
||||
for e in batch
|
||||
)
|
||||
template = self._dream_template()
|
||||
files_section = self._render_current_memory_files()
|
||||
prompt = (
|
||||
f"{template}\n\n{files_section}\n\n"
|
||||
f"## Conversation History\n{history_text}"
|
||||
)
|
||||
prompt = f"{template}\n\n## Conversation History\n{history_text}"
|
||||
return (prompt, batch[-1]["cursor"])
|
||||
|
||||
def _render_current_memory_files(self) -> str:
|
||||
"""Render the durable memory files' current contents for the Dream prompt.
|
||||
|
||||
Missing files render as ``(empty)``; oversized files are capped. The
|
||||
section is the ground truth the model must edit against.
|
||||
"""
|
||||
files = [
|
||||
("SOUL.md", self.soul_file),
|
||||
("USER.md", self.user_file),
|
||||
("memory/MEMORY.md", self.memory_file),
|
||||
]
|
||||
blocks: list[str] = []
|
||||
for label, path in files:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
except OSError:
|
||||
content = ""
|
||||
if len(content) > self._DREAM_FILE_EMBED_CAP:
|
||||
content = truncate_text(content, self._DREAM_FILE_EMBED_CAP) + "\n...[truncated]"
|
||||
blocks.append(f"### {label}\n{content}" if content.strip() else f"### {label}\n(empty)")
|
||||
return "## Current Memory Files\n" + "\n\n".join(blocks)
|
||||
|
||||
def dream_content_diff(self) -> str:
|
||||
"""Structured summary of uncommitted changes to the durable memory files.
|
||||
|
||||
@@ -718,21 +668,28 @@ class MemoryStore:
|
||||
*,
|
||||
max_chars: int | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||
formatted = truncate_text(
|
||||
self._format_messages(public_history_messages(messages)),
|
||||
limit,
|
||||
)
|
||||
self.append_history(
|
||||
f"[RAW] {len(messages)} messages\n"
|
||||
f"{formatted}",
|
||||
session_key=session_key,
|
||||
)
|
||||
) -> str:
|
||||
"""Persist and return a bounded raw checkpoint when summarization degrades."""
|
||||
checkpoint = self._build_raw_checkpoint(messages, max_chars=max_chars)
|
||||
self.append_history(checkpoint, session_key=session_key)
|
||||
logger.warning(
|
||||
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
||||
)
|
||||
return checkpoint
|
||||
|
||||
def _build_raw_checkpoint(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
max_chars: int | None = None,
|
||||
) -> str:
|
||||
"""Build the same bounded checkpoint as :meth:`raw_archive` without writing it."""
|
||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||
checkpoint = (
|
||||
f"[RAW] {len(messages)} messages\n"
|
||||
f"{self._format_messages(public_history_messages(messages))}"
|
||||
)
|
||||
return self._normalize_history_entry(checkpoint, max_chars=limit)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dream helpers
|
||||
@@ -784,14 +741,13 @@ class MemoryStore:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory ingestion and legacy context-pressure coordination
|
||||
# Memory ingestion and context-pressure coordination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Individual history.jsonl writers cap their own payloads tightly; the
|
||||
# _HISTORY_ENTRY_HARD_CAP at append_history() is a belt-and-suspenders default
|
||||
# that catches any new caller that forgot to set its own cap.
|
||||
# Raw fallbacks use a tighter cap. Completed model summaries may scale with the
|
||||
# configured generation budget, while append_history() still enforces the
|
||||
# emergency hard cap against pathological provider output.
|
||||
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
||||
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
||||
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
||||
|
||||
|
||||
@@ -809,64 +765,174 @@ class MemoryArchiver:
|
||||
build_messages: Callable[..., list[dict[str, Any]]],
|
||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self._build_messages = build_messages
|
||||
self._get_tool_definitions = get_tool_definitions
|
||||
self._resolve_prompt_context = resolve_prompt_context
|
||||
self.unified_session = unified_session
|
||||
|
||||
async def archive(
|
||||
def _raw_checkpoint(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
session_key: str,
|
||||
previous_summary: str | None,
|
||||
max_tokens: int,
|
||||
) -> str:
|
||||
"""Persist the failed chunk and return a bounded replacement checkpoint."""
|
||||
raw = self.store.raw_archive(messages, session_key=session_key)
|
||||
return self._combine_raw_checkpoint(
|
||||
raw,
|
||||
previous_summary=previous_summary,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _combine_raw_checkpoint(
|
||||
raw: str,
|
||||
*,
|
||||
previous_summary: str | None,
|
||||
max_tokens: int,
|
||||
) -> str:
|
||||
"""Return a bounded checkpoint that preserves prior and newly archived context."""
|
||||
token_limit = max(1, max_tokens)
|
||||
if not previous_summary:
|
||||
return truncate_text_to_tokens(raw, token_limit)
|
||||
|
||||
combined = (
|
||||
"[Previous archived context]\n"
|
||||
f"{previous_summary}\n\n"
|
||||
"[Newly archived raw context]\n"
|
||||
f"{raw}"
|
||||
)
|
||||
bounded = truncate_text_to_tokens(combined, token_limit)
|
||||
if bounded == combined:
|
||||
return combined
|
||||
|
||||
# Keep evidence from both sides when their full concatenation cannot fit.
|
||||
section_limit = max(1, (token_limit - 32) // 2)
|
||||
return truncate_text_to_tokens(
|
||||
"[Previous archived context]\n"
|
||||
f"{truncate_text_to_tokens(previous_summary, section_limit)}\n\n"
|
||||
"[Newly archived raw context]\n"
|
||||
f"{truncate_text_to_tokens(raw, section_limit)}",
|
||||
token_limit,
|
||||
)
|
||||
|
||||
async def archive(
|
||||
self,
|
||||
source_messages: list[dict[str, Any]],
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str,
|
||||
request_messages: list[dict[str, Any]],
|
||||
history: list[dict[str, Any]],
|
||||
request_tools: list[dict[str, Any]],
|
||||
previous_summary: str | None = None,
|
||||
input_token_budget: int | None = None,
|
||||
fallback_max_tokens: int | None = None,
|
||||
provider_state: ProviderConversationState | None = None,
|
||||
) -> str | None:
|
||||
"""Execute a prepared archive request and persist its result."""
|
||||
if not messages:
|
||||
"""Append the archive prompt to H and persist its summary."""
|
||||
if not source_messages:
|
||||
return None
|
||||
|
||||
def raw_fallback() -> str:
|
||||
return self._raw_checkpoint(
|
||||
source_messages,
|
||||
session_key=session_key,
|
||||
previous_summary=previous_summary,
|
||||
max_tokens=(
|
||||
fallback_max_tokens
|
||||
if fallback_max_tokens is not None
|
||||
else runtime.generation.max_tokens
|
||||
),
|
||||
)
|
||||
|
||||
prompt = render_template(
|
||||
"agent/consolidator_archive.md",
|
||||
strip=True,
|
||||
archive_count=len(source_messages),
|
||||
)
|
||||
prompt_message = {"role": "user", "content": prompt}
|
||||
provider_context = None
|
||||
call_tools = request_tools
|
||||
if provider_state is not None:
|
||||
if not runtime.provider.can_resume_conversation_state(
|
||||
provider_state,
|
||||
runtime.model,
|
||||
):
|
||||
return raw_fallback()
|
||||
instruction_messages: list[dict[str, Any]] = []
|
||||
for message in history:
|
||||
if message.get("role") not in {"system", "developer"}:
|
||||
break
|
||||
instruction_messages.append(dict(message))
|
||||
request_messages = [*instruction_messages, prompt_message]
|
||||
provider_context = ProviderCallContext(
|
||||
conversation_state=provider_state.with_pending_messages([
|
||||
*provider_state.pending_messages,
|
||||
prompt_message,
|
||||
]),
|
||||
context_window_tokens=runtime.context_window_tokens,
|
||||
session_id=session_key,
|
||||
)
|
||||
call_tools = []
|
||||
else:
|
||||
request_messages = [
|
||||
*[dict(message) for message in history],
|
||||
prompt_message,
|
||||
]
|
||||
if input_token_budget is not None and provider_context is None:
|
||||
estimated, source = estimate_prompt_tokens_chain(
|
||||
runtime.provider,
|
||||
runtime.model,
|
||||
request_messages,
|
||||
call_tools,
|
||||
)
|
||||
if input_token_budget <= 0 or estimated > input_token_budget:
|
||||
logger.debug(
|
||||
"Memory archive input does not fit for {}: {}/{} via {}; raw-dumping",
|
||||
session_key,
|
||||
estimated,
|
||||
input_token_budget,
|
||||
source,
|
||||
)
|
||||
return raw_fallback()
|
||||
|
||||
try:
|
||||
with llm_usage_source("dream"):
|
||||
response = await runtime.provider.chat_with_retry(
|
||||
model=runtime.model,
|
||||
messages=request_messages,
|
||||
tools=request_tools,
|
||||
tool_choice="none",
|
||||
tools=call_tools,
|
||||
temperature=runtime.generation.temperature,
|
||||
max_tokens=runtime.generation.max_tokens,
|
||||
reasoning_effort=runtime.generation.reasoning_effort,
|
||||
provider_context=provider_context,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Memory archive provider call failed, raw-dumping to history")
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
return raw_fallback()
|
||||
if response.finish_reason in {"error", "length"}:
|
||||
logger.warning(
|
||||
"Memory archive provider did not complete ({}), raw-dumping to history",
|
||||
response.finish_reason,
|
||||
)
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
return raw_fallback()
|
||||
if response.has_tool_calls is True:
|
||||
logger.warning("Memory archive provider returned tool calls, raw-dumping to history")
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
return raw_fallback()
|
||||
summary = response.content
|
||||
if not summary or not summary.strip():
|
||||
logger.warning("Memory archive provider returned no summary, raw-dumping to history")
|
||||
self.store.raw_archive(messages, session_key=session_key)
|
||||
return None
|
||||
if summary.strip() == "(nothing)":
|
||||
return raw_fallback()
|
||||
summary = self.store._normalize_history_entry(summary)
|
||||
if not summary:
|
||||
logger.warning("Memory archive provider summary was not safe to replay, raw-dumping")
|
||||
return raw_fallback()
|
||||
if summary == "(nothing)":
|
||||
return "(nothing)"
|
||||
self.store.append_history(
|
||||
summary,
|
||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||
session_key=session_key,
|
||||
)
|
||||
self.store.append_history(summary, session_key=session_key)
|
||||
return summary
|
||||
|
||||
async def archive_session(
|
||||
@@ -881,13 +947,23 @@ class MemoryArchiver:
|
||||
messages = list(session.messages[session.last_archived:archive_end])
|
||||
if not messages:
|
||||
return None
|
||||
session_summary = session_summary_from_metadata(
|
||||
session.metadata,
|
||||
fallback_last_active=session.updated_at,
|
||||
)
|
||||
previous_summary = session_summary["text"] if session_summary else None
|
||||
|
||||
if input_token_budget <= 0:
|
||||
logger.debug(
|
||||
"Memory archive has no safe input budget for {}; raw-dumping",
|
||||
session.key,
|
||||
)
|
||||
self.store.raw_archive(messages, session_key=session.key)
|
||||
return None
|
||||
return self._raw_checkpoint(
|
||||
messages,
|
||||
session_key=session.key,
|
||||
previous_summary=previous_summary,
|
||||
max_tokens=runtime.generation.max_tokens,
|
||||
)
|
||||
prefix = Session(
|
||||
key=session.key,
|
||||
messages=list(session.messages[:archive_end]),
|
||||
@@ -903,57 +979,37 @@ class MemoryArchiver:
|
||||
"Memory archive cannot replay the full chunk for {}; raw-dumping",
|
||||
session.key,
|
||||
)
|
||||
self.store.raw_archive(messages, session_key=session.key)
|
||||
return None
|
||||
prompt = render_template(
|
||||
"agent/consolidator_archive.md",
|
||||
strip=True,
|
||||
archive_count=len(archive_history),
|
||||
return self._raw_checkpoint(
|
||||
messages,
|
||||
session_key=session.key,
|
||||
previous_summary=previous_summary,
|
||||
max_tokens=runtime.generation.max_tokens,
|
||||
)
|
||||
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
||||
workspace: Path | None = None
|
||||
if self._resolve_prompt_context is not None:
|
||||
channel, workspace = self._resolve_prompt_context(session)
|
||||
request_messages = self._build_messages(
|
||||
history_messages = self._build_messages(
|
||||
history=history,
|
||||
current_message=prompt,
|
||||
current_message=None,
|
||||
channel=channel,
|
||||
session_summary=session_summary_from_metadata(
|
||||
session.metadata,
|
||||
fallback_last_active=session.updated_at,
|
||||
),
|
||||
session_summary=session_summary,
|
||||
workspace=workspace,
|
||||
session_key=session.key,
|
||||
unified_session=self.unified_session,
|
||||
)
|
||||
tools = self._get_tool_definitions()
|
||||
estimated, source = estimate_prompt_tokens_chain(
|
||||
runtime.provider,
|
||||
runtime.model,
|
||||
request_messages,
|
||||
tools,
|
||||
)
|
||||
if estimated > input_token_budget:
|
||||
logger.debug(
|
||||
"Memory archive prefix exceeds budget for {}; raw-dumping: {}/{} via {}",
|
||||
session.key,
|
||||
estimated,
|
||||
input_token_budget,
|
||||
source,
|
||||
)
|
||||
self.store.raw_archive(messages, session_key=session.key)
|
||||
return None
|
||||
return await self.archive(
|
||||
messages,
|
||||
runtime=runtime,
|
||||
session_key=session.key,
|
||||
request_messages=request_messages,
|
||||
history=history_messages,
|
||||
request_tools=tools,
|
||||
previous_summary=previous_summary,
|
||||
input_token_budget=input_token_budget,
|
||||
)
|
||||
|
||||
|
||||
class Consolidator:
|
||||
"""Legacy context-pressure coordinator backed by a MemoryArchiver."""
|
||||
"""Coordinate session Memory checkpoints through ``MemoryArchiver``."""
|
||||
|
||||
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
||||
|
||||
@@ -964,20 +1020,16 @@ class Consolidator:
|
||||
build_messages: Callable[..., list[dict[str, Any]]],
|
||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||
resolve_prompt_context: Callable[[Session], tuple[str | None, Path | None]] | None = None,
|
||||
unified_session: bool = False,
|
||||
):
|
||||
self.store = store
|
||||
self.sessions = sessions
|
||||
self.unified_session = unified_session
|
||||
self._build_messages = build_messages
|
||||
self._get_tool_definitions = get_tool_definitions
|
||||
self._resolve_prompt_context = resolve_prompt_context
|
||||
self.archiver = MemoryArchiver(
|
||||
store=store,
|
||||
build_messages=build_messages,
|
||||
get_tool_definitions=get_tool_definitions,
|
||||
resolve_prompt_context=resolve_prompt_context,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
weakref.WeakValueDictionary()
|
||||
@@ -987,22 +1039,73 @@ class Consolidator:
|
||||
"""Return the shared consolidation lock for one session."""
|
||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
def pick_consolidation_boundary(
|
||||
async def summarize_transcript(
|
||||
self,
|
||||
session: Session,
|
||||
) -> int | None:
|
||||
"""Return the fixed user-led boundary before the recent replay tail."""
|
||||
if not session.messages:
|
||||
accepted_messages: list[dict[str, Any]],
|
||||
previous_summary: str | None,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str,
|
||||
tools: list[dict[str, Any]],
|
||||
provider_state: ProviderConversationState | None = None,
|
||||
) -> str | None:
|
||||
"""Summarize the exact transcript prefix already accepted by the model."""
|
||||
source_messages = [
|
||||
dict(message)
|
||||
for message in accepted_messages
|
||||
if message.get("role") != "system"
|
||||
]
|
||||
if not source_messages:
|
||||
return None
|
||||
boundary = max(0, len(session.messages) - MIN_COMPACTED_REPLAY_MESSAGES)
|
||||
while boundary > 0 and session.messages[boundary].get("role") != "user":
|
||||
boundary -= 1
|
||||
if (
|
||||
boundary <= session.last_archived
|
||||
or session.messages[boundary].get("role") != "user"
|
||||
):
|
||||
|
||||
max_output_tokens = max(0, runtime.generation.max_tokens)
|
||||
input_token_budget = runtime.context_window_tokens - max_output_tokens
|
||||
checkpoint_tokens = min(
|
||||
max_output_tokens,
|
||||
max(1, (input_token_budget - self._SAFETY_BUFFER) // 2),
|
||||
)
|
||||
|
||||
summary = await self.archiver.archive(
|
||||
source_messages,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
history=accepted_messages,
|
||||
request_tools=tools,
|
||||
previous_summary=previous_summary,
|
||||
input_token_budget=input_token_budget,
|
||||
fallback_max_tokens=max(1, checkpoint_tokens),
|
||||
provider_state=provider_state,
|
||||
)
|
||||
if summary == "(nothing)":
|
||||
summary = self.archiver._raw_checkpoint(
|
||||
source_messages,
|
||||
session_key=session_key,
|
||||
previous_summary=previous_summary,
|
||||
max_tokens=max_output_tokens,
|
||||
)
|
||||
if summary is None:
|
||||
return None
|
||||
return boundary
|
||||
return truncate_text_to_tokens(summary, max(1, max_output_tokens))
|
||||
|
||||
async def summarize_provider_compaction(
|
||||
self,
|
||||
state: ProviderConversationState,
|
||||
fallback_messages: list[dict[str, Any]],
|
||||
previous_summary: str | None,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str,
|
||||
tools: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Prompt a native compacted state without replaying its raw history."""
|
||||
return await self.summarize_transcript(
|
||||
fallback_messages,
|
||||
previous_summary,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
tools=tools,
|
||||
provider_state=state,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _full_replay_history(
|
||||
@@ -1013,13 +1116,18 @@ class Consolidator:
|
||||
return []
|
||||
return session.get_history()
|
||||
|
||||
def _persist_last_summary(self, session: Session, summary: str | None) -> None:
|
||||
if summary and summary != "(nothing)":
|
||||
@staticmethod
|
||||
def _set_last_summary(
|
||||
session: Session,
|
||||
summary: str,
|
||||
*,
|
||||
last_active: datetime | None = None,
|
||||
) -> None:
|
||||
if summary != "(nothing)":
|
||||
session.metadata["_last_summary"] = {
|
||||
"text": summary,
|
||||
"last_active": session.updated_at.isoformat(),
|
||||
"last_active": (last_active or session.updated_at).isoformat(),
|
||||
}
|
||||
self.sessions.save(session)
|
||||
|
||||
def estimate_session_prompt_tokens(
|
||||
self,
|
||||
@@ -1039,8 +1147,6 @@ class Consolidator:
|
||||
current_message="[token-probe]",
|
||||
channel=channel,
|
||||
session_summary=summary,
|
||||
session_key=session.key,
|
||||
unified_session=self.unified_session,
|
||||
)
|
||||
return estimate_prompt_tokens_chain(
|
||||
runtime.provider,
|
||||
@@ -1057,24 +1163,6 @@ class Consolidator:
|
||||
- self._SAFETY_BUFFER
|
||||
)
|
||||
|
||||
async def archive(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str,
|
||||
request_messages: list[dict[str, Any]],
|
||||
request_tools: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Compatibility wrapper for the extracted MemoryArchiver."""
|
||||
return await self.archiver.archive(
|
||||
messages,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
request_messages=request_messages,
|
||||
request_tools=request_tools,
|
||||
)
|
||||
|
||||
async def archive_session(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -1082,7 +1170,7 @@ class Consolidator:
|
||||
archive_end: int,
|
||||
runtime: LLMRuntime,
|
||||
) -> str | None:
|
||||
"""Compatibility wrapper for the extracted MemoryArchiver."""
|
||||
"""Archive one captured session range through the shared Memory path."""
|
||||
return await self.archiver.archive_session(
|
||||
session,
|
||||
archive_end=archive_end,
|
||||
@@ -1090,88 +1178,6 @@ class Consolidator:
|
||||
input_token_budget=self._input_token_budget(runtime),
|
||||
)
|
||||
|
||||
async def maybe_consolidate_by_tokens(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
) -> None:
|
||||
"""Archive one fixed old prefix when the prompt exceeds the safe budget.
|
||||
|
||||
The budget reserves space for completion tokens and a safety buffer
|
||||
so the LLM request never exceeds the context window.
|
||||
"""
|
||||
if runtime.context_window_tokens <= 0:
|
||||
return
|
||||
|
||||
lock = self.get_lock(session.key)
|
||||
async with lock:
|
||||
# Refresh session reference: AutoCompact may have replaced it.
|
||||
fresh = self.sessions.get_or_create(session.key)
|
||||
if fresh is not session:
|
||||
session = fresh
|
||||
if not session.messages:
|
||||
return
|
||||
|
||||
budget = self._input_token_budget(runtime)
|
||||
last_summary: str | None = None
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
if estimated <= 0:
|
||||
self._persist_last_summary(session, last_summary)
|
||||
return
|
||||
if estimated < budget:
|
||||
unarchived_count = len(session.messages) - session.last_archived
|
||||
logger.debug(
|
||||
"Token consolidation idle {}: {}/{} via {}, msgs={}",
|
||||
session.key,
|
||||
estimated,
|
||||
runtime.context_window_tokens,
|
||||
source,
|
||||
unarchived_count,
|
||||
)
|
||||
self._persist_last_summary(session, last_summary)
|
||||
return
|
||||
|
||||
end_idx = self.pick_consolidation_boundary(session)
|
||||
if end_idx is None:
|
||||
logger.debug(
|
||||
"Token consolidation: no safe fixed boundary for {}",
|
||||
session.key,
|
||||
)
|
||||
return
|
||||
|
||||
chunk = session.messages[session.last_archived:end_idx]
|
||||
if not chunk:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Token consolidation for {}: {}/{} via {}, chunk={} msgs",
|
||||
session.key,
|
||||
estimated,
|
||||
runtime.context_window_tokens,
|
||||
source,
|
||||
len(chunk),
|
||||
)
|
||||
summary = await self.archive_session(
|
||||
session,
|
||||
archive_end=end_idx,
|
||||
runtime=runtime,
|
||||
)
|
||||
# Advance either way: archive_session raw-archives on degradation,
|
||||
# and replaying the same chunk would duplicate Memory material.
|
||||
if summary:
|
||||
last_summary = summary
|
||||
session.last_archived = end_idx
|
||||
self.sessions.save(session)
|
||||
|
||||
# Persist the last summary to session metadata so it can be injected
|
||||
# into the runtime context on the next prepare_session() call, aligning
|
||||
# the summary injection strategy with AutoCompact._archive().
|
||||
self._persist_last_summary(session, last_summary)
|
||||
|
||||
async def compact_idle_session(
|
||||
self,
|
||||
session_key: str,
|
||||
@@ -1209,12 +1215,10 @@ class Consolidator:
|
||||
archive_end=archive_end,
|
||||
runtime=runtime,
|
||||
)
|
||||
if summary is None:
|
||||
return None
|
||||
|
||||
if summary and summary != "(nothing)":
|
||||
session.metadata["_last_summary"] = {
|
||||
"text": summary,
|
||||
"last_active": last_active.isoformat(),
|
||||
}
|
||||
self._set_last_summary(session, summary, last_active=last_active)
|
||||
|
||||
# A turn can append while the provider call is in flight. Advance only
|
||||
# through the captured batch so new messages remain eligible next time.
|
||||
|
||||
+178
-184
@@ -14,9 +14,15 @@ from typing import Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.context_governance import (
|
||||
ContextCompactionState,
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
HistoryConsolidator,
|
||||
ModelRequestState,
|
||||
ProviderCompactionConsolidator,
|
||||
TranscriptBuilder,
|
||||
)
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||
from nanobot.agent.tools.execution import execute_tool_calls
|
||||
@@ -31,20 +37,10 @@ from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
LLMUsage,
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.providers.conversation_state import (
|
||||
ProviderConversationStateController,
|
||||
allows_conversation_message_merge,
|
||||
)
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_MESSAGE_META,
|
||||
detach_runtime_context,
|
||||
reattach_runtime_context,
|
||||
)
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
||||
from nanobot.providers.conversation_state import ProviderConversationStateController
|
||||
from nanobot.session.summary import SessionSummaryCheckpoint
|
||||
from nanobot.utils.helpers import (
|
||||
build_assistant_message,
|
||||
estimate_message_tokens,
|
||||
@@ -94,11 +90,13 @@ def _restore_outer_whitespace(content: str, original: str | None) -> str:
|
||||
class AgentRunSpec:
|
||||
"""Configuration for a single agent execution."""
|
||||
|
||||
initial_messages: list[dict[str, Any]]
|
||||
initial_messages: list[dict[str, Any]] | None
|
||||
tools: ToolRegistry
|
||||
runtime: LLMRuntime
|
||||
max_iterations: int
|
||||
max_tool_result_chars: int
|
||||
transcript_input: TranscriptInput | None = None
|
||||
transcript_builder: TranscriptBuilder | None = None
|
||||
hook: AgentHook | None = None
|
||||
error_message: str | None = _DEFAULT_ERROR_MESSAGE
|
||||
max_iterations_message: str | None = None
|
||||
@@ -109,6 +107,8 @@ class AgentRunSpec:
|
||||
provider_retry_mode: str = "standard"
|
||||
retry_wait_callback: RetryWaitCallback | None = None
|
||||
checkpoint_callback: CheckpointCallback | None = None
|
||||
consolidate_history: HistoryConsolidator | None = None
|
||||
consolidate_provider_compaction: ProviderCompactionConsolidator | None = None
|
||||
injection_callback: InjectionCallback | None = None
|
||||
terminal_injection_callback: InjectionCallback | None = None
|
||||
llm_timeout_s: float | None = None
|
||||
@@ -133,6 +133,8 @@ class AgentRunResult:
|
||||
# Terminal tail to emit when the preceding final-content prefix was already streamed.
|
||||
pending_stream_content: str | None = None
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
summary_checkpoint: SessionSummaryCheckpoint | None = field(default=None, repr=False)
|
||||
provider_compaction_applied: bool = field(default=False, repr=False)
|
||||
|
||||
|
||||
class AgentRunner:
|
||||
@@ -142,118 +144,12 @@ class AgentRunner:
|
||||
self.context_governor = ContextGovernor()
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
if isinstance(left, str) and isinstance(right, str):
|
||||
return f"{left}\n\n{right}" if left else right
|
||||
|
||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
cast(dict[str, Any], item)
|
||||
if isinstance(item, dict)
|
||||
else {"type": "text", "text": str(item)}
|
||||
for item in cast(list[Any], value)
|
||||
]
|
||||
if value is None:
|
||||
return []
|
||||
return [{"type": "text", "text": str(value)}]
|
||||
|
||||
return _to_blocks(left) + _to_blocks(right)
|
||||
|
||||
@classmethod
|
||||
def _append_injected_messages(
|
||||
cls,
|
||||
messages: list[dict[str, Any]],
|
||||
injections: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Append injected user messages while preserving role alternation."""
|
||||
for injection in injections:
|
||||
if (
|
||||
messages
|
||||
and injection.get("role") == "user"
|
||||
and messages[-1].get("role") == "user"
|
||||
and not is_hidden_history_message(injection)
|
||||
and not is_hidden_history_message(messages[-1])
|
||||
and allows_conversation_message_merge(messages[-1])
|
||||
):
|
||||
merged = dict(messages[-1])
|
||||
left_meta = merged.get("_meta")
|
||||
right_meta = injection.get("_meta")
|
||||
left_meta_dict = cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
|
||||
right_meta_dict = (
|
||||
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
|
||||
)
|
||||
left_marker = (
|
||||
left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||
if left_meta_dict is not None
|
||||
else None
|
||||
)
|
||||
right_marker = (
|
||||
right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||
if right_meta_dict is not None
|
||||
else None
|
||||
)
|
||||
left_marker_dict = (
|
||||
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
|
||||
)
|
||||
right_marker_dict = (
|
||||
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
|
||||
)
|
||||
empty_sources: list[str] = []
|
||||
empty_blocks: list[dict[str, Any]] = []
|
||||
detached_left = (
|
||||
detach_runtime_context(merged.get("content"), left_marker_dict)
|
||||
if left_marker_dict is not None
|
||||
else (merged.get("content"), empty_sources, empty_blocks)
|
||||
)
|
||||
detached_right = (
|
||||
detach_runtime_context(injection.get("content"), right_marker_dict)
|
||||
if right_marker_dict is not None
|
||||
else (injection.get("content"), empty_sources, empty_blocks)
|
||||
)
|
||||
if detached_left is not None and detached_right is not None:
|
||||
left_content, left_sources, left_blocks = detached_left
|
||||
right_content, right_sources, right_blocks = detached_right
|
||||
merged_content = cls._merge_message_content(left_content, right_content)
|
||||
context_blocks = [*left_blocks, *right_blocks]
|
||||
if context_blocks:
|
||||
merged_content, marker = reattach_runtime_context(
|
||||
merged_content,
|
||||
[*left_sources, *right_sources],
|
||||
context_blocks,
|
||||
)
|
||||
internal_meta = dict(left_meta_dict) if left_meta_dict is not None else {}
|
||||
if right_meta_dict is not None:
|
||||
for key, value in right_meta_dict.items():
|
||||
internal_meta.setdefault(key, value)
|
||||
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
|
||||
merged["_meta"] = internal_meta
|
||||
merged["content"] = merged_content
|
||||
else:
|
||||
merged["content"] = cls._merge_message_content(
|
||||
merged.get("content"),
|
||||
injection.get("content"),
|
||||
)
|
||||
followup_id = injection.get(PENDING_FOLLOWUP_ID_KEY)
|
||||
if isinstance(followup_id, str) and followup_id:
|
||||
existing = cast(object, merged.get(PENDING_FOLLOWUP_ID_KEY))
|
||||
followup_ids = (
|
||||
[existing]
|
||||
if isinstance(existing, str)
|
||||
else [
|
||||
item
|
||||
for item in cast(list[object], existing)
|
||||
if isinstance(item, str)
|
||||
]
|
||||
if isinstance(existing, list)
|
||||
else []
|
||||
)
|
||||
if followup_id not in followup_ids:
|
||||
followup_ids.append(followup_id)
|
||||
merged[PENDING_FOLLOWUP_ID_KEY] = followup_ids
|
||||
messages[-1] = merged
|
||||
continue
|
||||
messages.append(injection)
|
||||
"""Append injected messages without rewriting the raw transcript."""
|
||||
messages.extend(injections)
|
||||
|
||||
async def _try_drain_injections(
|
||||
self,
|
||||
@@ -410,7 +306,7 @@ class AgentRunner:
|
||||
|
||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||
hook = spec.hook or AgentHook()
|
||||
messages = list(spec.initial_messages)
|
||||
messages, compaction = self._initial_transcript_and_compaction(spec)
|
||||
context = AgentRunHookContext(messages=deepcopy(messages))
|
||||
llm_usage_source_token = bind_llm_usage_source(
|
||||
spec.llm_usage_source or source_from_session_key(spec.session_key)
|
||||
@@ -418,7 +314,7 @@ class AgentRunner:
|
||||
|
||||
try:
|
||||
await hook.before_run(context)
|
||||
result = await self._run_core(spec, hook, messages)
|
||||
result = await self._run_core(spec, hook, messages, compaction)
|
||||
except asyncio.CancelledError as exc:
|
||||
context.messages = deepcopy(messages)
|
||||
context.stop_reason = "cancelled"
|
||||
@@ -462,11 +358,36 @@ class AgentRunner:
|
||||
finally:
|
||||
reset_llm_usage_source(llm_usage_source_token)
|
||||
|
||||
@staticmethod
|
||||
def _initial_transcript_and_compaction(
|
||||
spec: AgentRunSpec,
|
||||
) -> tuple[list[dict[str, Any]], ContextCompactionState | None]:
|
||||
"""Build the initial transcript and its optional compaction state."""
|
||||
transcript_input = spec.transcript_input
|
||||
if transcript_input is not None:
|
||||
if spec.initial_messages is not None:
|
||||
raise ValueError("provide either transcript_input or initial_messages, not both")
|
||||
transcript_builder = spec.transcript_builder
|
||||
if transcript_builder is None:
|
||||
raise ValueError("transcript_builder is required with transcript_input")
|
||||
return ContextCompactionState.from_transcript(
|
||||
transcript_input,
|
||||
transcript_builder,
|
||||
spec.consolidate_history,
|
||||
spec.consolidate_provider_compaction,
|
||||
)
|
||||
if spec.initial_messages is None:
|
||||
raise ValueError("initial_messages is required without transcript_input")
|
||||
if spec.consolidate_history is not None:
|
||||
raise ValueError("consolidate_history requires transcript_input")
|
||||
return list(spec.initial_messages), None
|
||||
|
||||
async def _run_core(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
hook: AgentHook,
|
||||
messages: list[dict[str, Any]],
|
||||
compaction: ContextCompactionState | None,
|
||||
) -> AgentRunResult:
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = []
|
||||
@@ -483,7 +404,6 @@ class AgentRunner:
|
||||
length_recovery_parts: list[str] = []
|
||||
had_injections = False
|
||||
injection_cycles = 0
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
pending_stream_content: str | None = None
|
||||
conversation_state = ProviderConversationStateController(
|
||||
provider=spec.runtime.provider,
|
||||
@@ -502,40 +422,42 @@ class AgentRunner:
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
context_block_limit=spec.context_block_limit,
|
||||
max_tokens=spec.runtime.generation.max_tokens,
|
||||
inflight_start_index=len(spec.initial_messages),
|
||||
)
|
||||
request_state = ModelRequestState(
|
||||
config=governance_config,
|
||||
conversation=conversation_state,
|
||||
compaction=compaction,
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
# Keep the persisted conversation untouched. Context governance
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn. A governance
|
||||
# failure must stop the run instead of sending an ungoverned copy.
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
context = AgentHookContext(
|
||||
iteration=iteration,
|
||||
messages=messages,
|
||||
session_key=spec.session_key,
|
||||
)
|
||||
await hook.before_iteration(context)
|
||||
provider_context = conversation_state.prepare_request(
|
||||
messages,
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
model_messages=messages_for_model,
|
||||
request_message_count = len(messages)
|
||||
request_messages = (
|
||||
request_state.compaction.request_messages(messages)
|
||||
if request_state.compaction is not None
|
||||
else messages
|
||||
)
|
||||
response = await self._request_model(
|
||||
spec,
|
||||
messages_for_model,
|
||||
request_messages,
|
||||
hook,
|
||||
context,
|
||||
conversation_state=conversation_state,
|
||||
provider_context=provider_context,
|
||||
request_state=request_state,
|
||||
transcript=messages,
|
||||
)
|
||||
assert request_state.messages is not None
|
||||
messages_for_model = request_state.messages
|
||||
conversation_state.observe_response(response, messages)
|
||||
if request_state.compaction is not None:
|
||||
request_state.compaction.accept_request(
|
||||
messages_for_model,
|
||||
raw_boundary=request_message_count,
|
||||
)
|
||||
context.response = response
|
||||
context.tool_calls = list(response.tool_calls)
|
||||
|
||||
@@ -546,7 +468,7 @@ class AgentRunner:
|
||||
response.content,
|
||||
)
|
||||
response.content = cleaned_content
|
||||
raw_usage = self._usage_or_estimate(spec, messages_for_model, response)
|
||||
raw_usage = self._record_request_usage(spec, request_state, response)
|
||||
context.usage = raw_usage
|
||||
usage = self._merge_usage(usage, raw_usage)
|
||||
if reasoning_text and not context.streamed_reasoning:
|
||||
@@ -617,10 +539,9 @@ class AgentRunner:
|
||||
messages.append(tool_message)
|
||||
completed_tool_results.append(tool_message)
|
||||
checkpoint_model_messages = (
|
||||
self.context_governor.prepare_for_model(
|
||||
self.context_governor.prepare_messages_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
if response.provider_state is not None
|
||||
else None
|
||||
@@ -686,14 +607,13 @@ class AgentRunner:
|
||||
)
|
||||
if hook.wants_streaming():
|
||||
await hook.on_stream_end(context, resuming=False)
|
||||
retry_messages = self._finalization_retry_messages(messages_for_model)
|
||||
response = await self._request_finalization_retry(
|
||||
spec,
|
||||
messages_for_model,
|
||||
request_state=request_state,
|
||||
transcript=messages,
|
||||
conversation_state=conversation_state,
|
||||
)
|
||||
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
|
||||
retry_usage = self._record_request_usage(spec, request_state, response)
|
||||
usage = self._merge_usage(usage, retry_usage)
|
||||
raw_usage = self._merge_usage(raw_usage, retry_usage)
|
||||
context.response = response
|
||||
@@ -880,7 +800,7 @@ class AgentRunner:
|
||||
hook,
|
||||
messages,
|
||||
usage,
|
||||
conversation_state,
|
||||
request_state=request_state,
|
||||
)
|
||||
if terminal_content is None:
|
||||
terminal_content = self._max_iterations_fallback(spec)
|
||||
@@ -905,6 +825,12 @@ class AgentRunner:
|
||||
had_injections=had_injections,
|
||||
pending_stream_content=pending_stream_content,
|
||||
provider_state=conversation_state.finish(messages),
|
||||
summary_checkpoint=(
|
||||
request_state.compaction.summary_checkpoint
|
||||
if request_state.compaction is not None
|
||||
else None
|
||||
),
|
||||
provider_compaction_applied=request_state.provider_compaction_applied,
|
||||
)
|
||||
|
||||
def _build_request_kwargs(
|
||||
@@ -934,21 +860,29 @@ class AgentRunner:
|
||||
hook: AgentHook,
|
||||
context: AgentHookContext,
|
||||
*,
|
||||
request_state: ModelRequestState,
|
||||
malformed_retry: bool = False,
|
||||
conversation_state: ProviderConversationStateController,
|
||||
provider_context: ProviderCallContext | None = None,
|
||||
transcript: list[dict[str, Any]] | None,
|
||||
) -> LLMResponse:
|
||||
timeout_s = self._resolve_llm_timeout_s(spec)
|
||||
tool_definitions = spec.tools.get_definitions()
|
||||
messages, provider_context = await self.context_governor.prepare_request(
|
||||
request_state,
|
||||
messages,
|
||||
tool_definitions=tool_definitions,
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
kwargs = self._build_request_kwargs(
|
||||
spec,
|
||||
messages,
|
||||
tools=spec.tools.get_definitions(),
|
||||
tools=tool_definitions,
|
||||
)
|
||||
wants_streaming = hook.wants_streaming()
|
||||
|
||||
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
||||
native_reasoning_open = False
|
||||
native_reasoning_close_task: asyncio.Task[None] | None = None
|
||||
request_started_at = 0.0
|
||||
first_output_at: float | None = None
|
||||
generation_started_at: float | None = None
|
||||
@@ -972,11 +906,29 @@ class AgentRunner:
|
||||
generation_started_at = None
|
||||
|
||||
async def _close_native_reasoning() -> None:
|
||||
nonlocal native_reasoning_open
|
||||
nonlocal native_reasoning_open, native_reasoning_close_task
|
||||
if native_reasoning_close_task is None:
|
||||
if not native_reasoning_open:
|
||||
return
|
||||
native_reasoning_open = False
|
||||
await hook.emit_reasoning_end()
|
||||
native_reasoning_close_task = asyncio.create_task(
|
||||
hook.emit_reasoning_end()
|
||||
)
|
||||
|
||||
close_task = native_reasoning_close_task
|
||||
cancellation: asyncio.CancelledError | None = None
|
||||
while not close_task.done():
|
||||
try:
|
||||
await asyncio.shield(close_task)
|
||||
except asyncio.CancelledError as exc:
|
||||
cancellation = cancellation or exc
|
||||
try:
|
||||
close_task.result()
|
||||
finally:
|
||||
if native_reasoning_close_task is close_task:
|
||||
native_reasoning_close_task = None
|
||||
if cancellation is not None:
|
||||
raise cancellation
|
||||
|
||||
async def _provider_tool_event(event: dict[str, Any]) -> None:
|
||||
if event.get("kind") != "hosted_tool":
|
||||
@@ -1051,6 +1003,10 @@ class AgentRunner:
|
||||
await coro if outer_timeout_s is None
|
||||
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
_pause_generation()
|
||||
await _close_native_reasoning()
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
if outer_timeout_s is None:
|
||||
response = LLMResponse(
|
||||
@@ -1070,6 +1026,12 @@ class AgentRunner:
|
||||
response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000))
|
||||
if generation_elapsed_s > 0:
|
||||
response.generation_ms = max(1, round(generation_elapsed_s * 1000))
|
||||
await self.context_governor.summarize_provider_compaction(
|
||||
request_state,
|
||||
response,
|
||||
current_request_boundary=(len(transcript) if transcript is not None else None),
|
||||
)
|
||||
request_state.provider_compaction_applied |= response.provider_compaction_applied
|
||||
# chat_stream_with_retry may recover internally, so only fail unfinished
|
||||
# hosted calls after the provider returns its final error response.
|
||||
if response.finish_reason == "error":
|
||||
@@ -1098,11 +1060,9 @@ class AgentRunner:
|
||||
)
|
||||
return await self._request_model(
|
||||
spec, retry_messages, hook, context,
|
||||
request_state=request_state,
|
||||
malformed_retry=True,
|
||||
conversation_state=conversation_state,
|
||||
provider_context=conversation_state.independent_request_context(
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
),
|
||||
transcript=None,
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
@@ -1118,9 +1078,7 @@ class AgentRunner:
|
||||
return await self._request_no_tools(
|
||||
spec,
|
||||
fallback_messages,
|
||||
provider_context=conversation_state.independent_request_context(
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
),
|
||||
request_state=request_state,
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -1188,21 +1146,17 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
request_state: ModelRequestState,
|
||||
transcript: list[dict[str, Any]],
|
||||
conversation_state: ProviderConversationStateController,
|
||||
) -> LLMResponse:
|
||||
retry_messages = self._finalization_retry_messages(messages)
|
||||
provider_context = conversation_state.prepare_request(
|
||||
transcript,
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
supplemental_messages=[retry_messages[-1]],
|
||||
)
|
||||
response = await self._request_no_tools(
|
||||
spec,
|
||||
retry_messages,
|
||||
provider_context=provider_context,
|
||||
request_state=request_state,
|
||||
transcript=transcript,
|
||||
)
|
||||
conversation_state.observe_response(
|
||||
request_state.conversation.observe_response(
|
||||
response,
|
||||
transcript,
|
||||
adopt_candidate_state=False,
|
||||
@@ -1221,16 +1175,22 @@ class AgentRunner:
|
||||
hook: AgentHook,
|
||||
messages: list[dict[str, Any]],
|
||||
usage: LLMUsage | None,
|
||||
conversation_state: ProviderConversationStateController,
|
||||
*,
|
||||
request_state: ModelRequestState,
|
||||
) -> tuple[str | None, LLMUsage | None]:
|
||||
retry_messages = self._budget_exhausted_finalization_messages(messages)
|
||||
compaction = request_state.compaction
|
||||
request_messages = (
|
||||
compaction.request_messages(messages)
|
||||
if compaction is not None
|
||||
else messages
|
||||
)
|
||||
retry_messages = self._budget_exhausted_finalization_messages(request_messages)
|
||||
try:
|
||||
response = await self._request_no_tools(
|
||||
spec,
|
||||
retry_messages,
|
||||
provider_context=conversation_state.independent_request_context(
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
),
|
||||
request_state=request_state,
|
||||
transcript=messages if compaction is not None else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -1239,7 +1199,7 @@ class AgentRunner:
|
||||
)
|
||||
return None, usage
|
||||
|
||||
raw_usage = self._usage_or_estimate(spec, retry_messages, response)
|
||||
raw_usage = self._record_request_usage(spec, request_state, response)
|
||||
usage = self._merge_usage(usage, raw_usage)
|
||||
if response.finish_reason == "error" or response.has_tool_calls:
|
||||
logger.warning(
|
||||
@@ -1268,8 +1228,15 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
provider_context: ProviderCallContext | None = None,
|
||||
request_state: ModelRequestState,
|
||||
transcript: list[dict[str, Any]] | None = None,
|
||||
) -> LLMResponse:
|
||||
messages, provider_context = await self.context_governor.prepare_request(
|
||||
request_state,
|
||||
messages,
|
||||
tool_definitions=None,
|
||||
transcript=transcript,
|
||||
)
|
||||
kwargs = self._build_request_kwargs(
|
||||
spec,
|
||||
messages,
|
||||
@@ -1281,17 +1248,24 @@ class AgentRunner:
|
||||
)
|
||||
timeout_s = self._resolve_llm_timeout_s(spec)
|
||||
try:
|
||||
return (
|
||||
response = (
|
||||
await coro
|
||||
if timeout_s is None
|
||||
else await asyncio.wait_for(coro, timeout=timeout_s)
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return LLMResponse(
|
||||
response = LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
await self.context_governor.summarize_provider_compaction(
|
||||
request_state,
|
||||
response,
|
||||
current_request_boundary=(len(transcript) if transcript is not None else None),
|
||||
)
|
||||
request_state.provider_compaction_applied |= response.provider_compaction_applied
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _resolve_llm_timeout_s(spec: AgentRunSpec) -> float | None:
|
||||
@@ -1333,33 +1307,53 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
response: LLMResponse,
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
) -> LLMUsage | None:
|
||||
usage = response.usage
|
||||
if response.finish_reason == "error":
|
||||
if usage is None or usage.total_tokens == 0:
|
||||
usage = LLMUsage.empty_request()
|
||||
elif usage is None or usage.total_tokens == 0:
|
||||
usage = self._estimate_response_usage(spec, messages, response)
|
||||
usage = self._estimate_response_usage(
|
||||
spec,
|
||||
messages,
|
||||
response,
|
||||
tool_definitions=tool_definitions,
|
||||
)
|
||||
return usage.with_timing(
|
||||
generation_ms=response.generation_ms,
|
||||
ttft_ms=response.ttft_ms,
|
||||
)
|
||||
|
||||
def _record_request_usage(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
state: ModelRequestState,
|
||||
response: LLMResponse,
|
||||
) -> LLMUsage | None:
|
||||
assert state.messages is not None
|
||||
state.usage = self._usage_or_estimate(
|
||||
spec,
|
||||
state.messages,
|
||||
response,
|
||||
tool_definitions=state.tool_definitions,
|
||||
)
|
||||
return state.usage
|
||||
|
||||
def _estimate_response_usage(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
response: LLMResponse,
|
||||
*,
|
||||
tool_definitions: list[dict[str, Any]] | None,
|
||||
) -> LLMUsage:
|
||||
try:
|
||||
tools = spec.tools.get_definitions()
|
||||
except Exception:
|
||||
tools = None
|
||||
prompt_tokens, _ = estimate_prompt_tokens_chain(
|
||||
spec.runtime.provider,
|
||||
spec.runtime.model,
|
||||
messages,
|
||||
tools,
|
||||
tool_definitions,
|
||||
)
|
||||
assistant_message = build_assistant_message(
|
||||
response.content or "",
|
||||
|
||||
@@ -43,6 +43,13 @@ _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _with_retry_hint(payload: str) -> str:
|
||||
"""Append the recovery hint exactly once."""
|
||||
if payload.endswith(_RETRY_HINT):
|
||||
return payload
|
||||
return payload + _RETRY_HINT
|
||||
|
||||
|
||||
async def execute_tool_calls(
|
||||
tools: ToolRegistry,
|
||||
tool_calls: list[ToolCallRequest],
|
||||
@@ -105,7 +112,7 @@ async def _execute_tool_call(
|
||||
"status": "error",
|
||||
"detail": "repeated external lookup blocked",
|
||||
}
|
||||
return lookup_error + _RETRY_HINT, event
|
||||
return _with_retry_hint(lookup_error), event
|
||||
|
||||
prepare_call = cast(
|
||||
Callable[[str, Any], object] | None,
|
||||
@@ -119,6 +126,7 @@ async def _execute_tool_call(
|
||||
if len(prepared_tuple) == 3:
|
||||
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
|
||||
if prep_error:
|
||||
payload = _with_retry_hint(prep_error)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -126,14 +134,14 @@ async def _execute_tool_call(
|
||||
}
|
||||
handled = _classify_violation(
|
||||
raw_text=prep_error,
|
||||
soft_payload=prep_error + _RETRY_HINT,
|
||||
soft_payload=payload,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
return prep_error + _RETRY_HINT, event
|
||||
return payload, event
|
||||
|
||||
await hook.before_execute_tool(context, tool_call, tool, params)
|
||||
try:
|
||||
@@ -150,10 +158,9 @@ async def _execute_tool_call(
|
||||
"status": "error",
|
||||
"detail": str(exc),
|
||||
}
|
||||
payload = f"Error: {type(exc).__name__}: {exc}"
|
||||
payload = _with_retry_hint(f"Error: {type(exc).__name__}: {exc}")
|
||||
handled = _classify_violation(
|
||||
raw_text=str(exc),
|
||||
# Preserve legacy exception payloads without the retry hint.
|
||||
soft_payload=payload,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
@@ -165,6 +172,7 @@ async def _execute_tool_call(
|
||||
|
||||
if is_tool_error_result(result):
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||
payload = _with_retry_hint(result)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -172,14 +180,14 @@ async def _execute_tool_call(
|
||||
}
|
||||
handled = _classify_violation(
|
||||
raw_text=result,
|
||||
soft_payload=result + _RETRY_HINT,
|
||||
soft_payload=payload,
|
||||
event=event,
|
||||
tool_call=tool_call,
|
||||
workspace_violation_counts=workspace_violation_counts,
|
||||
)
|
||||
if handled is not None:
|
||||
return handled
|
||||
return result + _RETRY_HINT, event
|
||||
return payload, event
|
||||
|
||||
await hook.after_execute_tool(context, tool_call, tool, params, result)
|
||||
|
||||
|
||||
@@ -861,8 +861,10 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
path=StringSchema("The file path to edit"),
|
||||
old_text=StringSchema("The text to find and replace"),
|
||||
new_text=StringSchema("The text to replace with"),
|
||||
old_text=StringSchema("The text to find and replace; copy it from read_file."),
|
||||
new_text=StringSchema(
|
||||
"The replacement text; must differ from old_text for an existing file."
|
||||
),
|
||||
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
||||
occurrence=IntegerSchema(
|
||||
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
||||
@@ -899,15 +901,9 @@ class EditFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Perform a small, exact replacement in one file by replacing "
|
||||
"old_text with new_text. When replacing text in an existing file, "
|
||||
"old_text and new_text must be different. Use this for narrow text substitutions "
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||
"multiple times, provide more context or set occurrence, line_hint, "
|
||||
"replace_all, and expected_replacements. When editing from numbered "
|
||||
"read_file output, set line_hint to the exact target line. "
|
||||
"Shows closest-match diagnostics on failure."
|
||||
"Perform a small, exact replacement in one file. "
|
||||
"Prefer apply_patch for multi-file, structural, or generated edits. "
|
||||
"occurrence, line_hint, and replace_all=true are mutually exclusive."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections import deque
|
||||
from collections import OrderedDict, deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
@@ -127,7 +127,7 @@ class SendSessionMessageTool(Tool):
|
||||
self._max_messages_per_minute = max_messages_per_minute
|
||||
self._schedule_later = schedule_later
|
||||
self._clock = clock or time.monotonic
|
||||
self._sent_at: dict[str, deque[float]] = {}
|
||||
self._sent_at: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._pending_replies: dict[tuple[str, str], _PendingReply] = {}
|
||||
self._expiry_tasks: set[asyncio.Task[None]] = set()
|
||||
self._send_lock = asyncio.Lock()
|
||||
@@ -240,8 +240,11 @@ class SendSessionMessageTool(Tool):
|
||||
|
||||
async with self._send_lock:
|
||||
now = self._clock()
|
||||
sent_at = self._sent_at.setdefault(source.session_key, deque())
|
||||
cutoff = now - _RATE_LIMIT_WINDOW_SECONDS
|
||||
self._prune_expired_rate_limits(cutoff)
|
||||
sent_at = self._sent_at.get(source.session_key)
|
||||
if sent_at is None:
|
||||
sent_at = deque[float]()
|
||||
while sent_at and sent_at[0] <= cutoff:
|
||||
sent_at.popleft()
|
||||
if len(sent_at) >= self._max_messages_per_minute:
|
||||
@@ -259,6 +262,8 @@ class SendSessionMessageTool(Tool):
|
||||
input_role="user",
|
||||
))
|
||||
sent_at.append(now)
|
||||
self._sent_at[source.session_key] = sent_at
|
||||
self._sent_at.move_to_end(source.session_key)
|
||||
self._cancel_pending_reply(reverse_wait_key)
|
||||
if timeout_seconds is not None:
|
||||
self._cancel_pending_reply(wait_key)
|
||||
@@ -271,6 +276,14 @@ class SendSessionMessageTool(Tool):
|
||||
|
||||
return f"@{target.name}"
|
||||
|
||||
def _prune_expired_rate_limits(self, cutoff: float) -> None:
|
||||
"""Drop sources ordered by their most recent successful send."""
|
||||
while self._sent_at:
|
||||
_, sent_at = next(iter(self._sent_at.items()))
|
||||
if sent_at[-1] > cutoff:
|
||||
return
|
||||
self._sent_at.popitem(last=False)
|
||||
|
||||
@staticmethod
|
||||
def _validate_reply_timeout(
|
||||
expect_reply: bool,
|
||||
|
||||
@@ -182,6 +182,12 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
|
||||
)
|
||||
)
|
||||
|
||||
if not self.channel._accepting_inbound_tasks:
|
||||
self.channel.logger.debug(
|
||||
"Skipping DingTalk inbound dispatch during channel shutdown"
|
||||
)
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
|
||||
|
||||
# Forward to Nanobot via _on_message (non-blocking).
|
||||
@@ -196,7 +202,7 @@ class NanobotDingTalkHandler(_CallbackHandlerBase):
|
||||
)
|
||||
)
|
||||
self.channel._background_tasks.add(task)
|
||||
task.add_done_callback(self.channel._background_tasks.discard)
|
||||
task.add_done_callback(self.channel._on_background_task_done)
|
||||
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
@@ -256,6 +262,17 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
# Hold references to background tasks to prevent GC
|
||||
self._background_tasks: set[asyncio.Task[None]] = set()
|
||||
self._accepting_inbound_tasks = True
|
||||
|
||||
def _on_background_task_done(self, task: asyncio.Task[None]) -> None:
|
||||
self._background_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
exception = task.exception()
|
||||
if exception is not None:
|
||||
self.logger.opt(exception=exception).error(
|
||||
"DingTalk inbound message task failed"
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the DingTalk bot with Stream Mode."""
|
||||
@@ -272,6 +289,7 @@ class DingTalkChannel(BaseChannel):
|
||||
self.logger.error("client_id and client_secret not configured")
|
||||
return
|
||||
|
||||
self._accepting_inbound_tasks = True
|
||||
self._running = True
|
||||
self._http = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=10.0, read=30.0, write=30.0, pool=10.0)
|
||||
@@ -309,6 +327,7 @@ class DingTalkChannel(BaseChannel):
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the DingTalk bot."""
|
||||
self._accepting_inbound_tasks = False
|
||||
self._running = False
|
||||
await self._close_stream_client()
|
||||
start_task = self._start_task
|
||||
@@ -326,8 +345,11 @@ class DingTalkChannel(BaseChannel):
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
# Cancel outstanding background tasks
|
||||
for task in self._background_tasks:
|
||||
background_tasks = tuple(self._background_tasks)
|
||||
for task in background_tasks:
|
||||
task.cancel()
|
||||
if background_tasks:
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
|
||||
async def _close_stream_client(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -402,6 +402,61 @@ async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatc
|
||||
assert msg.chat_id == "group:conv123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_retrieves_background_message_failure(monkeypatch) -> None:
|
||||
bus = MessageBus()
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
bus,
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
failure = RuntimeError("inbound dispatch failed")
|
||||
mock_logger = MagicMock()
|
||||
channel.logger = mock_logger
|
||||
|
||||
class _FakeChatbotMessage:
|
||||
text = SimpleNamespace(content="hello")
|
||||
extensions = {}
|
||||
sender_staff_id = "user1"
|
||||
sender_id = "fallback-user"
|
||||
sender_nick = "Alice"
|
||||
message_type = "text"
|
||||
|
||||
@staticmethod
|
||||
def from_dict(_data):
|
||||
return _FakeChatbotMessage()
|
||||
|
||||
async def fail(*_args) -> None:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
monkeypatch.setattr(channel, "_on_message", fail)
|
||||
event_loop = asyncio.get_running_loop()
|
||||
previous_handler = event_loop.get_exception_handler()
|
||||
loop_errors: list[dict[str, object]] = []
|
||||
event_loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
|
||||
|
||||
try:
|
||||
status, body = await handler.process(
|
||||
SimpleNamespace(data={"conversationType": "1", "text": {"content": "hello"}})
|
||||
)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if not channel._background_tasks:
|
||||
break
|
||||
finally:
|
||||
event_loop.set_exception_handler(previous_handler)
|
||||
|
||||
assert (status, body) == ("OK", "OK")
|
||||
assert not channel._background_tasks
|
||||
assert not loop_errors
|
||||
mock_logger.opt.assert_called_once_with(exception=failure)
|
||||
mock_logger.opt.return_value.error.assert_called_once_with(
|
||||
"DingTalk inbound message task failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||
"""Test that file messages are handled and forwarded with downloaded path."""
|
||||
@@ -451,6 +506,72 @@ async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_does_not_spawn_message_task_after_stop_during_download(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
MessageBus(),
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
download_started = asyncio.Event()
|
||||
release_download = asyncio.Event()
|
||||
message_task_started = asyncio.Event()
|
||||
|
||||
class _FakeFileChatbotMessage:
|
||||
text = None
|
||||
extensions = {}
|
||||
image_content = None
|
||||
rich_text_content = None
|
||||
sender_staff_id = "user1"
|
||||
sender_id = "fallback-user"
|
||||
sender_nick = "Alice"
|
||||
message_type = "file"
|
||||
|
||||
@staticmethod
|
||||
def from_dict(_data):
|
||||
return _FakeFileChatbotMessage()
|
||||
|
||||
async def delayed_download(*_args):
|
||||
download_started.set()
|
||||
await release_download.wait()
|
||||
return "/tmp/nanobot_dingtalk/user1/report.xlsx"
|
||||
|
||||
async def block_message(*_args) -> None:
|
||||
message_task_started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
monkeypatch.setattr(channel, "_download_dingtalk_file", delayed_download)
|
||||
monkeypatch.setattr(channel, "_on_message", block_message)
|
||||
|
||||
process_task = asyncio.create_task(handler.process(SimpleNamespace(data={
|
||||
"conversationType": "1",
|
||||
"content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
|
||||
"text": {"content": ""},
|
||||
})))
|
||||
await download_started.wait()
|
||||
|
||||
try:
|
||||
await channel.stop()
|
||||
release_download.set()
|
||||
assert await process_task == ("OK", "OK")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not message_task_started.is_set()
|
||||
assert not channel._background_tasks
|
||||
finally:
|
||||
release_download.set()
|
||||
if not process_task.done():
|
||||
process_task.cancel()
|
||||
pending = tuple(channel._background_tasks)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(process_task, *pending, return_exceptions=True)
|
||||
|
||||
|
||||
def _rich_text_message(rich_text_list):
|
||||
class _FakeRichTextChatbotMessage:
|
||||
text = None
|
||||
@@ -650,6 +771,41 @@ async def test_stop_cancels_stream_client_after_sdk_swallows_first_cancel(monkey
|
||||
assert start_task.cancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_waits_for_background_message_tasks() -> None:
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
mock_logger = MagicMock()
|
||||
channel.logger = mock_logger
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def wait_forever() -> None:
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
task = asyncio.create_task(wait_forever())
|
||||
channel._background_tasks.add(task)
|
||||
task.add_done_callback(channel._on_background_task_done)
|
||||
await started.wait()
|
||||
|
||||
try:
|
||||
await channel.stop()
|
||||
assert task.done()
|
||||
assert cancelled.is_set()
|
||||
assert not channel._background_tasks
|
||||
mock_logger.opt.assert_not_called()
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
"""Test the two-step file download flow (get URL then download content)."""
|
||||
|
||||
@@ -430,7 +430,13 @@ class EmailChannel(BaseChannel):
|
||||
skipped_uids: set[str],
|
||||
cycle_uids: set[str],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Fetch messages by arbitrary IMAP search criteria."""
|
||||
"""Fetch messages by arbitrary IMAP search criteria.
|
||||
|
||||
Uses UID SEARCH so already-processed UIDs are recognized before any
|
||||
FETCH at all, then fetches headers only to evaluate every filter — the
|
||||
full body (and any attachments) is downloaded only for messages that
|
||||
pass every check and are actually going to be delivered.
|
||||
"""
|
||||
mailbox = self.config.imap_mailbox or "INBOX"
|
||||
|
||||
client = self._open_imap_client(mailbox=mailbox, missing_mailbox_ok=True)
|
||||
@@ -438,29 +444,30 @@ class EmailChannel(BaseChannel):
|
||||
return messages
|
||||
|
||||
try:
|
||||
status, data = client.search(None, *search_criteria)
|
||||
if status != "OK" or not data:
|
||||
status, data = client.uid("SEARCH", None, *search_criteria)
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return messages
|
||||
|
||||
ids = data[0].split()
|
||||
if limit > 0 and len(ids) > limit:
|
||||
ids = ids[-limit:]
|
||||
for imap_id in ids:
|
||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||
uids = [raw.decode("ascii", errors="ignore") for raw in data[0].split()]
|
||||
if limit > 0 and len(uids) > limit:
|
||||
uids = uids[-limit:]
|
||||
|
||||
features: _ServerFeatures | None = None
|
||||
|
||||
for uid in uids:
|
||||
if not uid or uid in cycle_uids:
|
||||
continue
|
||||
if dedupe and uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
status, fetched = client.uid("FETCH", uid, "(BODY.PEEK[HEADER])")
|
||||
if status != "OK" or not fetched:
|
||||
continue
|
||||
|
||||
raw_bytes = self._extract_message_bytes(fetched)
|
||||
if raw_bytes is None:
|
||||
header_bytes = self._extract_message_bytes(fetched)
|
||||
if header_bytes is None:
|
||||
continue
|
||||
|
||||
uid = self._extract_uid(fetched)
|
||||
if uid and uid in cycle_uids:
|
||||
continue
|
||||
if dedupe and uid and uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(header_bytes)
|
||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||
if not sender:
|
||||
continue
|
||||
@@ -468,8 +475,7 @@ class EmailChannel(BaseChannel):
|
||||
self.logger.info("From {} ignored: matches bot-owned address", sender)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
if uid:
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
@@ -482,7 +488,6 @@ class EmailChannel(BaseChannel):
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
if self.config.verify_dkim and not dkim_pass:
|
||||
@@ -492,18 +497,26 @@ class EmailChannel(BaseChannel):
|
||||
sender,
|
||||
)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if uid:
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
if not self.is_allowed(sender):
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
if uid:
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
skipped_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Passed every filter — only now fetch the full message body
|
||||
# (and any attachments) for the message we're actually delivering.
|
||||
status, full_fetched = client.uid("FETCH", uid, "(BODY.PEEK[])")
|
||||
if status != "OK" or not full_fetched:
|
||||
continue
|
||||
raw_bytes = self._extract_message_bytes(full_fetched)
|
||||
if raw_bytes is None:
|
||||
continue
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
|
||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
@@ -556,10 +569,19 @@ class EmailChannel(BaseChannel):
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
features = self._mark_seen_uid(client, uid, features)
|
||||
finally:
|
||||
self._close_imap_client(client)
|
||||
|
||||
def _mark_seen_uid(
|
||||
self, client: Any, uid: str, features: _ServerFeatures | None
|
||||
) -> _ServerFeatures:
|
||||
"""Mark a single UID \\Seen, reusing session-learned STORE support."""
|
||||
if features is None:
|
||||
features = self._server_features(client)
|
||||
self._uid_store_flag(client, uid, "\\Seen", features)
|
||||
return features
|
||||
|
||||
def _open_imap_client(self, mailbox: str, *, missing_mailbox_ok: bool = False) -> Any | None:
|
||||
if self.config.imap_use_ssl:
|
||||
client: Any = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
||||
@@ -714,11 +736,14 @@ class EmailChannel(BaseChannel):
|
||||
return data[0].split()[0]
|
||||
|
||||
def _uid_store_deleted(self, client: Any, uid: str, features: _ServerFeatures) -> bool:
|
||||
return self._uid_store_flag(client, uid, "\\Deleted", features)
|
||||
|
||||
def _uid_store_flag(self, client: Any, uid: str, flag: str, features: _ServerFeatures) -> bool:
|
||||
# Optimistic path: try UID STORE first because UID is stable and avoids
|
||||
# sequence-number lookup. If this fails once for the session, remember it
|
||||
# and use the sequence STORE fallback directly for remaining UIDs.
|
||||
if features.uid_store is not False:
|
||||
status, _ = client.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
status, _ = client.uid("STORE", uid, "+FLAGS", f"({flag})")
|
||||
if status == "OK":
|
||||
features.uid_store = True
|
||||
return True
|
||||
@@ -728,12 +753,12 @@ class EmailChannel(BaseChannel):
|
||||
# unreliable: resolve the current sequence number from UID and use STORE.
|
||||
imap_id = self._lookup_imap_id_by_uid(client, uid)
|
||||
if not imap_id:
|
||||
self.logger.warning("Post-action skipped: UID {} not found", uid)
|
||||
self.logger.warning("Could not locate UID {} to set flag {}", uid, flag)
|
||||
return False
|
||||
|
||||
status, _ = client.store(imap_id, "+FLAGS", "\\Deleted")
|
||||
status, _ = client.store(imap_id, "+FLAGS", flag)
|
||||
if status != "OK":
|
||||
self.logger.warning("Post-action failed: could not mark UID {} as deleted", uid)
|
||||
self.logger.warning("Failed to set flag {} on UID {}", flag, uid)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -773,16 +798,6 @@ class EmailChannel(BaseChannel):
|
||||
return bytes(fetched_item[1])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_uid(fetched: list[Any]) -> str:
|
||||
for item in fetched:
|
||||
if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)):
|
||||
head = bytes(item[0]).decode("utf-8", errors="ignore")
|
||||
m = re.search(r"UID\s+(\d+)", head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _decode_header_value(value: str) -> str:
|
||||
if not value:
|
||||
|
||||
@@ -53,30 +53,7 @@ def _make_raw_email(
|
||||
def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
||||
raw = _make_raw_email(subject="Invoice", body="Please pay")
|
||||
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
@@ -86,38 +63,25 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
||||
assert items[0]["sender"] == "alice@example.com"
|
||||
assert items[0]["subject"] == "Invoice"
|
||||
assert "Please pay" in items[0]["content"]
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
assert [call for call in fake.uid_calls if call[0] == "FETCH"] == [
|
||||
("FETCH", "123", "(BODY.PEEK[HEADER])"),
|
||||
("FETCH", "123", "(BODY.PEEK[])"),
|
||||
]
|
||||
assert skipped_uids == set()
|
||||
|
||||
# Same UID should be deduped in-process.
|
||||
items_again, skipped_again = channel._fetch_new_messages()
|
||||
assert items_again == []
|
||||
assert skipped_again == set()
|
||||
assert len([call for call in fake.uid_calls if call[0] == "FETCH"]) == 2
|
||||
|
||||
|
||||
def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> None:
|
||||
raw = _make_raw_email(subject="Invoice", body="Please pay")
|
||||
|
||||
class FakeIMAP:
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, _imap_id: bytes, _op: str, _flags: str):
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||
items, skipped_uids = channel._fetch_new_messages()
|
||||
@@ -130,26 +94,10 @@ def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> No
|
||||
def test_fetch_new_messages_rejected_returns_skipped_uid(monkeypatch) -> None:
|
||||
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
|
||||
|
||||
class FakeIMAP:
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, _imap_id: bytes, _op: str, _flags: str):
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.email.runtime.imaplib.IMAP4_SSL",
|
||||
lambda _h, _p: _make_fake_imap(raw, uid=b"123"),
|
||||
)
|
||||
|
||||
channel_skip = EmailChannel(
|
||||
_make_config(from_address="bot@example.com", post_action="delete", post_action_ignore_skipped=True),
|
||||
@@ -545,30 +493,7 @@ async def test_start_keeps_post_actions_for_successful_emails_when_later_deliver
|
||||
def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None:
|
||||
raw = _make_raw_email(from_addr="Nanobot <bot@example.com>", subject="Loop test")
|
||||
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
|
||||
@@ -576,7 +501,7 @@ def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) ->
|
||||
|
||||
assert items == []
|
||||
assert skipped_uids == {"123"}
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
|
||||
# Same UID should still be deduped after being ignored.
|
||||
items_again, skipped_again = channel._fetch_new_messages()
|
||||
@@ -614,37 +539,14 @@ def test_fetch_new_messages_skips_self_sent_across_identity_sources(
|
||||
imap_username matches, and must be case-insensitive."""
|
||||
raw = _make_raw_email(from_addr=from_header, subject="Loop test")
|
||||
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
fake = _make_fake_imap(raw, uid=b"123")
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(**config_override), MessageBus())
|
||||
items, _ = channel._fetch_new_messages()
|
||||
|
||||
assert items == []
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
assert ("STORE", "123", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
|
||||
|
||||
def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None:
|
||||
@@ -662,15 +564,16 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
self.search_calls += 1
|
||||
if fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
return "OK", [b"1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [b"123"]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -700,10 +603,7 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
|
||||
def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypatch) -> None:
|
||||
raw_first = _make_raw_email(subject="First", body="First body")
|
||||
raw_second = _make_raw_email(subject="Second", body="Second body")
|
||||
mailbox_state = {
|
||||
b"1": {"uid": b"123", "raw": raw_first, "seen": False},
|
||||
b"2": {"uid": b"124", "raw": raw_second, "seen": False},
|
||||
}
|
||||
mailbox_state = {"123": raw_first, "124": raw_second}
|
||||
fail_once = {"pending": True}
|
||||
|
||||
class FlakyIMAP:
|
||||
@@ -713,20 +613,18 @@ def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypa
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"2"]
|
||||
|
||||
def search(self, *_args):
|
||||
unseen_ids = [imap_id for imap_id, item in mailbox_state.items() if not item["seen"]]
|
||||
return "OK", [b" ".join(unseen_ids)]
|
||||
|
||||
def fetch(self, imap_id: bytes, _parts: str):
|
||||
if imap_id == b"2" and fail_once["pending"]:
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
keys = " ".join(sorted(mailbox_state.keys(), key=int))
|
||||
return "OK", [keys.encode()]
|
||||
if command == "FETCH":
|
||||
uid = args[0]
|
||||
if uid == "124" and fail_once["pending"]:
|
||||
fail_once["pending"] = False
|
||||
raise imaplib.IMAP4.abort("socket error")
|
||||
item = mailbox_state[imap_id]
|
||||
header = b"%s (UID %s BODY[] {200})" % (imap_id, item["uid"])
|
||||
return "OK", [(header, item["raw"]), b")"]
|
||||
|
||||
def store(self, imap_id: bytes, _op: str, _flags: str):
|
||||
mailbox_state[imap_id]["seen"] = True
|
||||
raw = mailbox_state[uid]
|
||||
header = f"{uid} (UID {uid} BODY[] {{200}})".encode()
|
||||
return "OK", [(header, raw), b")"]
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
@@ -1044,12 +942,13 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
self.search_args = _args
|
||||
return "OK", [b"5"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
def uid(self, command: str, *args):
|
||||
if command == "SEARCH":
|
||||
self.search_args = args
|
||||
return "OK", [b"999"]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"5 (UID 999 BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -1070,7 +969,7 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["subject"] == "Status"
|
||||
# search(None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
# uid("SEARCH", None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
assert fake.search_args is not None
|
||||
assert fake.search_args[1:] == ("SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
|
||||
assert fake.store_calls == []
|
||||
@@ -1080,11 +979,12 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
# Security: Anti-spoofing tests for Authentication-Results verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_fake_imap(raw: bytes):
|
||||
def _make_fake_imap(raw: bytes, uid: bytes = b"500"):
|
||||
"""Return a FakeIMAP class pre-loaded with the given raw email."""
|
||||
class FakeIMAP:
|
||||
def __init__(self) -> None:
|
||||
self.store_calls: list[tuple[bytes, str, str]] = []
|
||||
self.uid_calls: list[tuple] = []
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return "OK", [b"logged in"]
|
||||
@@ -1092,11 +992,16 @@ def _make_fake_imap(raw: bytes):
|
||||
def select(self, _mailbox: str):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def search(self, *_args):
|
||||
return "OK", [b"1"]
|
||||
def capability(self):
|
||||
return "OK", [b"IMAP4rev1"]
|
||||
|
||||
def fetch(self, _imap_id: bytes, _parts: str):
|
||||
return "OK", [(b"1 (UID 500 BODY[] {200})", raw), b")"]
|
||||
def uid(self, command: str, *args):
|
||||
self.uid_calls.append((command, *args))
|
||||
if command == "SEARCH":
|
||||
return "OK", [uid]
|
||||
if command == "FETCH":
|
||||
return "OK", [(b"1 (UID " + uid + b" BODY[] {200})", raw), b")"]
|
||||
return "OK", [b""]
|
||||
|
||||
def store(self, imap_id: bytes, op: str, flags: str):
|
||||
self.store_calls.append((imap_id, op, flags))
|
||||
@@ -1292,7 +1197,10 @@ def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monke
|
||||
|
||||
assert channel._fetch_new_messages() == ([], {"500"})
|
||||
assert called["attachments"] is False
|
||||
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
|
||||
assert [call for call in fake.uid_calls if call[0] == "FETCH"] == [
|
||||
("FETCH", "500", "(BODY.PEEK[HEADER])")
|
||||
]
|
||||
assert ("STORE", "500", "+FLAGS", "(\\Seen)") in fake.uid_calls
|
||||
|
||||
|
||||
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||
|
||||
@@ -897,6 +897,68 @@ class TelegramChannel(BaseChannel):
|
||||
self.logger.debug("sendRichMessage failed: {}", exc)
|
||||
return False
|
||||
|
||||
async def _try_edit_rich(self, chat_id: int, message_id: int, content: str) -> bool:
|
||||
"""Upgrade an existing message to rich in place via editMessageText (Bot API 10.1).
|
||||
|
||||
Editing in place keeps the message identity, so the streaming preview is
|
||||
upgraded without the delete-and-resend pattern that caused flickering and
|
||||
dropped line breaks (issue #4470).
|
||||
|
||||
Returns True when the rich edit is in place (including the ambiguous
|
||||
"message is not modified" retry outcome after a response timeout).
|
||||
Returns False only when the legacy HTML path should take over:
|
||||
capability errors (server older than Bot API 10.1, which also trip the
|
||||
rich latch) and content-shaped BadRequest rejections. Transport,
|
||||
rate-limit, and unexpected errors propagate so the final-edit retry
|
||||
contract is preserved — ChannelManager retries the buffered send
|
||||
instead of an immediate legacy edit doubling connection demand.
|
||||
"""
|
||||
if not self._app:
|
||||
return False
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"rich_message": {
|
||||
"markdown": content,
|
||||
},
|
||||
}
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.do_api_request,
|
||||
"editMessageText",
|
||||
api_kwargs=payload,
|
||||
)
|
||||
return True
|
||||
except BadRequest as exc:
|
||||
if self._is_not_modified_error(exc):
|
||||
# Ambiguous success: the rich edit was applied server-side but
|
||||
# its response timed out, so the retry hit "message is not
|
||||
# modified". Treat it as done rather than letting the legacy
|
||||
# edit overwrite the already-successful rich result.
|
||||
self.logger.debug("Rich stream edit already applied for {}", chat_id)
|
||||
return True
|
||||
# Before Bot API 10.1, editMessageText ignores rich_message and
|
||||
# reports the absent text argument instead.
|
||||
pre_rich_edit_server = (
|
||||
bool(content)
|
||||
and str(exc).strip().lower() == "message text is empty"
|
||||
)
|
||||
if self._is_rich_capability_error(exc) or pre_rich_edit_server:
|
||||
self.logger.debug("editMessageText rich_message not available, disabling")
|
||||
self._rich_send_disabled = True
|
||||
return False
|
||||
# Content-shaped rejections (invalid markdown, unsupported media in
|
||||
# the rich payload, …) fall back to the legacy HTML edit.
|
||||
self.logger.debug("editMessageText rich_message rejected: {}", exc)
|
||||
return False
|
||||
except Exception:
|
||||
# Transport, rate-limit, and unexpected errors propagate so the
|
||||
# final-edit retry contract stays intact: ChannelManager retries
|
||||
# the buffered send instead of this handler doubling connection
|
||||
# demand with an immediate legacy edit.
|
||||
raise
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Telegram."""
|
||||
app = await self._wait_for_app()
|
||||
@@ -1136,26 +1198,16 @@ class TelegramChannel(BaseChannel):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
raw_text = buf.text
|
||||
|
||||
# Try sendRichMessage for final output (Bot API 10.1).
|
||||
# Skip when a streaming preview already exists to avoid the
|
||||
# delete-and-resend pattern that causes flickering and drops
|
||||
# line breaks (issue #4470).
|
||||
if not buf.message_id and self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
|
||||
reply_params = None
|
||||
if reply_to_message_id := meta.get("message_id"):
|
||||
reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True}
|
||||
rich_ok = await self._try_send_rich(
|
||||
int_chat_id, raw_text, reply_params, thread_kwargs, None,
|
||||
)
|
||||
# Try upgrading the streaming preview to rich in place (Bot API 10.1:
|
||||
# editMessageText gained a rich_message parameter). Editing in place
|
||||
# keeps the message identity, so there is no delete-and-resend and
|
||||
# none of the flickering / dropped line breaks from issue #4470.
|
||||
# The previous branch here was unreachable: it was guarded by
|
||||
# ``not buf.message_id`` after an early return had already ensured
|
||||
# ``buf.message_id`` is set (issue #5516).
|
||||
if self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
|
||||
rich_ok = await self._try_edit_rich(int_chat_id, buf.message_id, raw_text)
|
||||
if rich_ok:
|
||||
# Delete the streaming preview message
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
app.bot.delete_message,
|
||||
chat_id=int_chat_id, message_id=buf.message_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # Preview stays if delete fails
|
||||
self._stream_bufs.pop(chat_id, None)
|
||||
return
|
||||
|
||||
|
||||
@@ -2735,3 +2735,130 @@ def test_markdown_to_html_code_block_same_line_no_newline() -> None:
|
||||
|
||||
stripped = _strip_md_block(text)
|
||||
assert stripped == "Use <tag> here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_upgrades_preview_to_rich_in_place() -> None:
|
||||
"""Rich messages finally work with streaming: the preview is upgraded via
|
||||
editMessageText rich_message (in place), not delete-and-resend (issue #5516)."""
|
||||
from telegram.error import BadRequest
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
_install_ready_app(channel)
|
||||
channel._app.bot.do_api_request = AsyncMock()
|
||||
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("should not be reached"))
|
||||
channel._stream_bufs["123"] = _StreamBuf(text="**hello**", message_id=7, last_edit=0.0)
|
||||
|
||||
await channel.send_delta("123", "", stream_end=True)
|
||||
|
||||
# editMessageText with rich_message payload, in place (same message_id)
|
||||
channel._app.bot.do_api_request.assert_awaited_once()
|
||||
args, kwargs = channel._app.bot.do_api_request.await_args
|
||||
assert args[0] == "editMessageText"
|
||||
assert kwargs["api_kwargs"]["chat_id"] == 123
|
||||
assert kwargs["api_kwargs"]["message_id"] == 7
|
||||
assert kwargs["api_kwargs"]["rich_message"] == {"markdown": "**hello**"}
|
||||
# No delete-and-resend, no legacy HTML edit
|
||||
channel._app.bot.edit_message_text.assert_not_awaited()
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_rich_capability_error_latches_and_falls_back() -> None:
|
||||
"""On a pre-10.1 Bot API server the rich edit fails, the latch trips, and the
|
||||
legacy HTML edit handles the final output."""
|
||||
from telegram.error import BadRequest
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
_install_ready_app(channel)
|
||||
# Before Bot API 10.1, editMessageText ignores rich_message and requires text.
|
||||
channel._app.bot.do_api_request = AsyncMock(
|
||||
side_effect=BadRequest("Message text is empty")
|
||||
)
|
||||
channel._app.bot.edit_message_text = AsyncMock()
|
||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||
|
||||
await channel.send_delta("123", "", stream_end=True)
|
||||
|
||||
channel._app.bot.do_api_request.assert_awaited_once()
|
||||
# Latch tripped: subsequent sends skip the rich path entirely
|
||||
assert channel._rich_send_disabled is True
|
||||
# Legacy HTML edit handled the final message
|
||||
channel._app.bot.edit_message_text.assert_awaited_once()
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_rich_disabled_uses_legacy_html() -> None:
|
||||
"""rich_messages=False (the default) keeps the legacy HTML path untouched."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
_install_ready_app(channel)
|
||||
channel._app.bot.do_api_request = AsyncMock()
|
||||
channel._app.bot.edit_message_text = AsyncMock()
|
||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||
|
||||
await channel.send_delta("123", "", stream_end=True)
|
||||
|
||||
channel._app.bot.do_api_request.assert_not_called()
|
||||
channel._app.bot.edit_message_text.assert_awaited_once()
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_rich_network_error_propagates_for_retry() -> None:
|
||||
"""A transport failure on the rich edit must propagate so ChannelManager
|
||||
retries the buffered send — not fall through to an immediate legacy edit
|
||||
that doubles connection demand during pool exhaustion."""
|
||||
from telegram.error import NetworkError
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
_install_ready_app(channel)
|
||||
channel._app.bot.do_api_request = AsyncMock(side_effect=NetworkError("pool exhausted"))
|
||||
channel._app.bot.edit_message_text = AsyncMock()
|
||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||
|
||||
with pytest.raises(NetworkError):
|
||||
await channel.send_delta("123", "", stream_end=True)
|
||||
|
||||
# No legacy fallback edit: the buffered state stays for the manager retry.
|
||||
channel._app.bot.edit_message_text.assert_not_awaited()
|
||||
assert "123" in channel._stream_bufs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_rich_not_modified_after_timeout_is_success() -> None:
|
||||
"""Ambiguous success: the rich edit applied server-side but its response
|
||||
timed out, so the retry hit "message is not modified". That is a completed
|
||||
rich upgrade — the legacy edit must not overwrite it."""
|
||||
from telegram.error import BadRequest, TimedOut
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
_install_ready_app(channel)
|
||||
# First attempt (inside _call_with_retry) times out, retry reports the
|
||||
# edit as already applied.
|
||||
channel._app.bot.do_api_request = AsyncMock(
|
||||
side_effect=[TimedOut(), BadRequest("Message is not modified")]
|
||||
)
|
||||
channel._app.bot.edit_message_text = AsyncMock(side_effect=AssertionError("must not overwrite rich result"))
|
||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||
|
||||
await channel.send_delta("123", "", stream_end=True)
|
||||
|
||||
assert channel._app.bot.do_api_request.await_count == 2
|
||||
channel._app.bot.edit_message_text.assert_not_awaited()
|
||||
assert "123" not in channel._stream_bufs
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
@@ -538,14 +539,32 @@ class WebSocketChannel(BaseChannel):
|
||||
# -- Server lifecycle and connection ingress ---------------------------
|
||||
|
||||
@staticmethod
|
||||
def _listener_is_serving(server: Server) -> bool:
|
||||
def _socket_is_accepting(sock: socket.socket) -> bool:
|
||||
"""Return whether a bound socket still advertises a listen capability.
|
||||
|
||||
``SO_ACCEPTCONN`` is not portable: macOS/BSD raise ``OSError`` with
|
||||
``ENOPROTOOPT`` ("Protocol not available") for this option even on a
|
||||
perfectly healthy listening socket. Treating that as "not serving"
|
||||
makes the listener look permanently degraded, so the caller retries
|
||||
forever and the channel never reaches a ready state. When the option
|
||||
is unavailable we fall back to the file-descriptor liveness check.
|
||||
"""
|
||||
if sock.fileno() < 0:
|
||||
return False
|
||||
try:
|
||||
return bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ENOPROTOOPT, errno.EOPNOTSUPP):
|
||||
return True
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def _listener_is_serving(cls, server: Server) -> bool:
|
||||
"""Return whether every bound socket still has a live listen capability."""
|
||||
try:
|
||||
sockets = server.sockets
|
||||
return bool(sockets) and server.is_serving() and all(
|
||||
sock.fileno() >= 0
|
||||
and bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
|
||||
for sock in sockets
|
||||
cls._socket_is_accepting(sock) for sock in sockets
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@@ -2333,6 +2333,48 @@ async def test_session_delete_removes_transcript_without_canonical_file(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_delete_removes_unpersisted_new_chat(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
sm = SessionManager(tmp_path / "sessions")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=_free_port())
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "new_chat",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
attached = next(
|
||||
payload
|
||||
for payload in (
|
||||
json.loads(call.args[0]) for call in connection.send.await_args_list
|
||||
)
|
||||
if payload.get("event") == "attached"
|
||||
)
|
||||
key = f"websocket:{attached['chat_id']}"
|
||||
|
||||
assert sm.list_sessions() == []
|
||||
assert channel.gateway.workspaces.scope_for_session_key(key).project_path == project.resolve()
|
||||
|
||||
response = await _webui_mutate(channel, "session.delete", {"key": key})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] is True
|
||||
assert channel.gateway.workspaces.scope_for_session_key(key).project_path == tmp_path.resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -662,11 +662,6 @@ def _run_gateway(
|
||||
if isinstance(message_tool, MessageTool) and suppress_token is not None:
|
||||
message_tool.reset_suppress_delivery(suppress_token)
|
||||
|
||||
# Keep a small tail of heartbeat history so the loop stays bounded.
|
||||
session = agent.sessions.get_or_create("heartbeat")
|
||||
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
||||
agent.sessions.save(session)
|
||||
|
||||
if not resp or not resp.content:
|
||||
return
|
||||
|
||||
|
||||
@@ -329,7 +329,6 @@ class HeartbeatConfig(Base):
|
||||
|
||||
enabled: bool = True
|
||||
interval_s: int = 30 * 60 # 30 minutes
|
||||
keep_recent_messages: int = 8
|
||||
|
||||
|
||||
class ApiConfig(Base):
|
||||
|
||||
+21
-3
@@ -25,6 +25,7 @@ from nanobot.cron.types import (
|
||||
CronSchedule,
|
||||
CronStore,
|
||||
)
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
from nanobot.utils.run_records import (
|
||||
write_run_record as write_automation_run_record,
|
||||
)
|
||||
@@ -115,8 +116,21 @@ def _disable_malformed_legacy_job(job: CronJob) -> None:
|
||||
logger.warning("Cron: disabled malformed legacy job '{}' ({}): {}", job.name, job.id, reason)
|
||||
|
||||
|
||||
def _persistable_origin_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a detached JSON-safe routing snapshot for a cron payload."""
|
||||
snapshot: dict[str, Any] = {}
|
||||
for key, value in metadata.items():
|
||||
if key == RUNTIME_CONTEXT_INPUT_META:
|
||||
continue
|
||||
try:
|
||||
snapshot[key] = json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
continue
|
||||
return snapshot
|
||||
|
||||
|
||||
def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
"""Migrate legacy user cron payloads into session-bound payloads.
|
||||
"""Make routing metadata persistable and migrate legacy user cron payloads.
|
||||
|
||||
Pre-bound user cron jobs stored their delivery target in ``channel``/``to``.
|
||||
Normal user-created legacy jobs always have those fields; if they are
|
||||
@@ -124,8 +138,12 @@ def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
a runtime legacy execution path.
|
||||
"""
|
||||
payload = job.payload
|
||||
origin_metadata = _persistable_origin_metadata(payload.origin_metadata)
|
||||
changed = origin_metadata != payload.origin_metadata
|
||||
payload.origin_metadata = origin_metadata
|
||||
|
||||
if payload.kind != "agent_turn" or not _has_legacy_delivery_context(payload):
|
||||
return False
|
||||
return changed
|
||||
|
||||
if not payload.channel or not payload.to:
|
||||
_disable_malformed_legacy_job(job)
|
||||
@@ -135,7 +153,7 @@ def _normalize_agent_turn_job(job: CronJob) -> bool:
|
||||
payload.origin_channel = payload.origin_channel or payload.channel
|
||||
payload.origin_chat_id = payload.origin_chat_id or payload.to
|
||||
if not payload.origin_metadata:
|
||||
payload.origin_metadata = dict(payload.channel_meta or {})
|
||||
payload.origin_metadata = _persistable_origin_metadata(payload.channel_meta or {})
|
||||
|
||||
payload.deliver = False
|
||||
payload.channel = None
|
||||
|
||||
@@ -34,6 +34,7 @@ from nanobot.providers.base import (
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
ResponsesStreamCapture,
|
||||
build_responses_compaction_state,
|
||||
build_responses_state,
|
||||
consume_sdk_stream,
|
||||
convert_tools,
|
||||
@@ -410,6 +411,16 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
output_items=capture.output_items,
|
||||
usage=usage,
|
||||
)
|
||||
result.provider_compaction_state = build_responses_compaction_state(
|
||||
provider=self._responses_state_provider(),
|
||||
model=str(body["model"]),
|
||||
output_items=capture.output_items,
|
||||
)
|
||||
result.provider_compaction_applied = (
|
||||
result.provider_compaction_state is not None
|
||||
)
|
||||
if result.provider_compaction_applied:
|
||||
result.provider_compaction_scope = "current_request"
|
||||
return result
|
||||
except Exception as e:
|
||||
return self._handle_error(e)
|
||||
|
||||
@@ -31,6 +31,7 @@ RETRY_AFTER_BUFFER = 1
|
||||
|
||||
RetryEventCallback = Callable[[str], Awaitable[None]]
|
||||
LLMCallObserver = Callable[["LLMCallRecord"], None]
|
||||
ProviderCompactionScope = Literal["prior_context", "current_request"]
|
||||
|
||||
|
||||
def resolve_stream_idle_timeout_s(
|
||||
@@ -563,6 +564,22 @@ class LLMResponse:
|
||||
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
||||
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
# True only when this response installed a new provider-native compaction
|
||||
# boundary. Replaying an older compaction item does not set this flag.
|
||||
provider_compaction_applied: bool = field(default=False, repr=False)
|
||||
# State immediately after native compaction, before the normal response
|
||||
# continues. An archive prompt can resume this state without replaying H.
|
||||
provider_compaction_state: ProviderConversationState | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
)
|
||||
# Which model input the native compaction state replaces. Providers that
|
||||
# compact before attaching the current request delta report
|
||||
# ``prior_context``; in-request compaction reports ``current_request``.
|
||||
provider_compaction_scope: ProviderCompactionScope | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
)
|
||||
# Routing wrappers may preserve or discard an incoming provider-owned
|
||||
# continuation independently of the final fallback error's retry policy.
|
||||
preserve_provider_state_on_error: bool | None = field(default=None, repr=False)
|
||||
@@ -1029,6 +1046,20 @@ class LLMProvider(ABC):
|
||||
# Unknown 429 defaults to WAIT+retry.
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _content_as_blocks(content: Any) -> list[dict[str, Any]]:
|
||||
"""Convert message content to blocks so mixed user content can be merged."""
|
||||
if isinstance(content, list):
|
||||
return [
|
||||
dict(cast(dict[str, Any], item))
|
||||
if isinstance(item, dict)
|
||||
else {"type": "text", "text": str(item)}
|
||||
for item in cast(list[object], content)
|
||||
]
|
||||
if content is None:
|
||||
return []
|
||||
return [{"type": "text", "text": str(content)}]
|
||||
|
||||
@staticmethod
|
||||
def _enforce_role_alternation(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge consecutive same-role messages and drop trailing assistant messages.
|
||||
@@ -1063,6 +1094,13 @@ class LLMProvider(ABC):
|
||||
curr_content = msg.get("content") or ""
|
||||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
||||
prev["content"] = (prev_content + "\n\n" + curr_content).strip()
|
||||
elif role == "user":
|
||||
combined = dict(msg)
|
||||
combined["content"] = [
|
||||
*LLMProvider._content_as_blocks(prev_content),
|
||||
*LLMProvider._content_as_blocks(curr_content),
|
||||
]
|
||||
merged[-1] = combined
|
||||
else:
|
||||
merged[-1] = dict(msg)
|
||||
else:
|
||||
|
||||
@@ -11,6 +11,7 @@ from nanobot.providers.base import (
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.utils.helpers import estimate_prompt_tokens_chain
|
||||
|
||||
_PROVIDER_STATE_OUTPUT_META = "provider_state_output"
|
||||
_PROVIDER_STATE_BOUNDARY_META = "provider_state_boundary"
|
||||
@@ -69,6 +70,43 @@ class ProviderConversationStateController:
|
||||
session_id=self._session_id,
|
||||
)
|
||||
|
||||
def estimate_request_context_tokens(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
model_messages: list[dict[str, Any]] | None = None,
|
||||
supplemental_messages: list[dict[str, Any]] | None = None,
|
||||
tool_definitions: list[dict[str, Any]] | None = None,
|
||||
) -> int | None:
|
||||
"""Estimate resumed state plus the pending delta for the next request."""
|
||||
state = self.checkpoint(messages, model_messages=model_messages)
|
||||
if state is None:
|
||||
return None
|
||||
context_tokens = state.payload.get("context_tokens")
|
||||
if (
|
||||
isinstance(context_tokens, bool)
|
||||
or not isinstance(context_tokens, int)
|
||||
or context_tokens < 0
|
||||
):
|
||||
return None
|
||||
pending_messages = [
|
||||
*state.pending_messages,
|
||||
*(supplemental_messages or []),
|
||||
]
|
||||
delta_tokens, _ = estimate_prompt_tokens_chain(
|
||||
self._provider,
|
||||
self._model,
|
||||
pending_messages,
|
||||
tool_definitions,
|
||||
)
|
||||
return context_tokens + max(0, delta_tokens)
|
||||
|
||||
def replace_transcript(self, messages: list[dict[str, Any]]) -> None:
|
||||
"""Discard append-only provider state after a transcript rewrite."""
|
||||
self._state = None
|
||||
self._boundary = len(messages)
|
||||
self._request_messages = []
|
||||
|
||||
def prepare_request(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -76,11 +114,20 @@ class ProviderConversationStateController:
|
||||
context_window_tokens: int | None,
|
||||
model_messages: list[dict[str, Any]] | None = None,
|
||||
supplemental_messages: list[dict[str, Any]] | None = None,
|
||||
resume_state: bool = True,
|
||||
) -> ProviderCallContext | None:
|
||||
"""Build typed context for the next request and remember its durable delta."""
|
||||
"""Build context for the next request and remember its durable delta.
|
||||
|
||||
``resume_state=False`` abandons opaque history when local request
|
||||
fitting has produced a new independent model-facing context.
|
||||
"""
|
||||
independent_context = self.independent_request_context(
|
||||
context_window_tokens=context_window_tokens,
|
||||
)
|
||||
if not resume_state:
|
||||
self._state = None
|
||||
self._request_messages = []
|
||||
return independent_context
|
||||
if self._state is None:
|
||||
self._request_messages = []
|
||||
return independent_context
|
||||
|
||||
@@ -31,6 +31,7 @@ from nanobot.providers.oauth_model_catalog import (
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
ResponsesStreamCapture,
|
||||
build_responses_compaction_state,
|
||||
build_responses_state,
|
||||
consume_sse_with_reasoning,
|
||||
convert_tools,
|
||||
@@ -137,6 +138,8 @@ class OpenAICodexProvider(LLMProvider):
|
||||
body.update(self._extra_body)
|
||||
|
||||
stage = "oauth_token"
|
||||
native_compaction_applied = False
|
||||
native_compaction_state: ProviderConversationState | None = None
|
||||
try:
|
||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||
headers = _build_headers(cast(str, token.account_id), token.access)
|
||||
@@ -187,9 +190,11 @@ class OpenAICodexProvider(LLMProvider):
|
||||
and responses_state_context_tokens(sanitized_state) >= compact_threshold
|
||||
):
|
||||
stage = "codex_compaction"
|
||||
history_items = responses_state_items(sanitized_state) or []
|
||||
delta_items = input_items[len(history_items):]
|
||||
compact_body = {
|
||||
**body,
|
||||
"input": [*input_items, {"type": "compaction_trigger"}],
|
||||
"input": [*history_items, {"type": "compaction_trigger"}],
|
||||
}
|
||||
try:
|
||||
compact_result = await _send(compact_body, emit_deltas=False)
|
||||
@@ -205,9 +210,16 @@ class OpenAICodexProvider(LLMProvider):
|
||||
}:
|
||||
raise RuntimeError("Codex compaction returned no compaction item")
|
||||
body["input"] = [
|
||||
*_retained_compaction_messages(input_items),
|
||||
*_retained_compaction_messages(history_items),
|
||||
*compact_items,
|
||||
*delta_items,
|
||||
]
|
||||
native_compaction_state = build_responses_compaction_state(
|
||||
provider=self._responses_state_provider(),
|
||||
model=_strip_model_prefix(model),
|
||||
output_items=compact_items,
|
||||
)
|
||||
native_compaction_applied = True
|
||||
except Exception as compact_error:
|
||||
if is_compaction_compatibility_error(compact_error):
|
||||
self._native_compaction_available = False
|
||||
@@ -220,7 +232,14 @@ class OpenAICodexProvider(LLMProvider):
|
||||
)
|
||||
|
||||
stage = "codex_request"
|
||||
return await _send(body, emit_deltas=True)
|
||||
result = await _send(body, emit_deltas=True)
|
||||
result.provider_compaction_applied = (
|
||||
result.provider_compaction_applied or native_compaction_applied
|
||||
)
|
||||
if native_compaction_state is not None:
|
||||
result.provider_compaction_state = native_compaction_state
|
||||
result.provider_compaction_scope = "prior_context"
|
||||
return result
|
||||
except Exception as e:
|
||||
response = _codex_error_response(e)
|
||||
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
|
||||
|
||||
@@ -36,6 +36,7 @@ from nanobot.providers.base import (
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
ResponsesStreamCapture,
|
||||
build_responses_compaction_state,
|
||||
build_responses_state,
|
||||
consume_sdk_stream,
|
||||
convert_tools,
|
||||
@@ -2049,6 +2050,18 @@ class OpenAICompatProvider(LLMProvider):
|
||||
output_items=capture.output_items,
|
||||
usage=usage,
|
||||
)
|
||||
result.provider_compaction_state = (
|
||||
build_responses_compaction_state(
|
||||
provider=self._responses_state_provider(),
|
||||
model=str(body["model"]),
|
||||
output_items=capture.output_items,
|
||||
)
|
||||
)
|
||||
result.provider_compaction_applied = (
|
||||
result.provider_compaction_state is not None
|
||||
)
|
||||
if result.provider_compaction_applied:
|
||||
result.provider_compaction_scope = "current_request"
|
||||
return result
|
||||
except Exception as responses_error:
|
||||
if self._spec and self._spec.name == "github_copilot":
|
||||
|
||||
@@ -18,6 +18,7 @@ from nanobot.providers.openai_responses.parsing import (
|
||||
parse_response_output,
|
||||
)
|
||||
from nanobot.providers.openai_responses.state import (
|
||||
build_responses_compaction_state,
|
||||
build_responses_state,
|
||||
is_compaction_compatibility_error,
|
||||
prepare_responses_input,
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"is_replayable_finish_reason",
|
||||
"map_finish_reason",
|
||||
"parse_response_output",
|
||||
"build_responses_compaction_state",
|
||||
"build_responses_state",
|
||||
"is_compaction_compatibility_error",
|
||||
"prepare_responses_input",
|
||||
|
||||
@@ -11,7 +11,10 @@ import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest, parse_tool_arguments
|
||||
from nanobot.providers.openai_responses.state import build_responses_state
|
||||
from nanobot.providers.openai_responses.state import (
|
||||
build_responses_compaction_state,
|
||||
build_responses_state,
|
||||
)
|
||||
|
||||
FINISH_REASON_MAP = {
|
||||
"completed": "stop",
|
||||
@@ -655,6 +658,14 @@ def parse_response_output(
|
||||
output_items=output,
|
||||
usage=usage,
|
||||
)
|
||||
result.provider_compaction_state = build_responses_compaction_state(
|
||||
provider=state_provider,
|
||||
model=state_model,
|
||||
output_items=output,
|
||||
)
|
||||
result.provider_compaction_applied = result.provider_compaction_state is not None
|
||||
if result.provider_compaction_applied:
|
||||
result.provider_compaction_scope = "current_request"
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,28 @@ def build_responses_state(
|
||||
)
|
||||
|
||||
|
||||
def build_responses_compaction_state(
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
output_items: list[dict[str, Any]],
|
||||
) -> ProviderConversationState | None:
|
||||
"""Return the state at the latest native compaction output boundary."""
|
||||
latest = None
|
||||
for index, item in enumerate(output_items):
|
||||
if item.get("type") in _COMPACTION_ITEM_TYPES:
|
||||
latest = index
|
||||
if latest is None:
|
||||
return None
|
||||
return ProviderConversationState(
|
||||
kind=RESPONSES_STATE_KIND,
|
||||
provider=provider,
|
||||
model=model,
|
||||
version=RESPONSES_STATE_VERSION,
|
||||
payload={_ITEMS_KEY: [deepcopy(output_items[latest])]},
|
||||
)
|
||||
|
||||
|
||||
def responses_state_items(
|
||||
state: ProviderConversationState,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
|
||||
@@ -209,11 +209,11 @@ class RuntimeClient:
|
||||
return self._loop.runtime_events.subscribe(handler, SessionTurnPersisted)
|
||||
|
||||
async def compact_session(self, session_key: str) -> SessionSnapshot:
|
||||
"""Run token consolidation for one session."""
|
||||
"""Archive one session through the shared idle-compaction path."""
|
||||
session = self._loop.sessions.get_or_create(session_key)
|
||||
runtime = self._loop.runtime_for_session(session)
|
||||
await self._loop.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
await self._loop.consolidator.compact_idle_session(
|
||||
session_key,
|
||||
runtime=runtime,
|
||||
)
|
||||
return snapshot_from_session(self._loop.sessions.get_or_create(session_key))
|
||||
|
||||
+14
-121
@@ -27,7 +27,9 @@ from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
public_history_message,
|
||||
)
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.session.summary import SUMMARY_CONTINUATION_TEXT
|
||||
from nanobot.utils.helpers import (
|
||||
content_with_media_breadcrumbs,
|
||||
ensure_dir,
|
||||
@@ -262,12 +264,6 @@ def _metadata_title(metadata: object) -> str:
|
||||
return strip_think(title)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetentionResult:
|
||||
dropped: list[dict[str, Any]]
|
||||
already_consolidated_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
"""Runtime rules that do not belong in durable session data."""
|
||||
@@ -286,9 +282,7 @@ class Session:
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
# Legacy storage name for the Memory ingestion watermark. New code should
|
||||
# use ``last_archived`` so this progress is not confused with model-context
|
||||
# compaction. Keep the field while persisted sessions and SDK callers migrate.
|
||||
# Keep the legacy storage name while persisted sessions and SDK callers migrate.
|
||||
last_consolidated: int = 0
|
||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
|
||||
@@ -309,7 +303,7 @@ class Session:
|
||||
|
||||
@property
|
||||
def last_archived(self) -> int:
|
||||
"""Number of transcript messages already written to the Memory journal."""
|
||||
"""End of the latest committed Memory checkpoint."""
|
||||
return self.last_consolidated
|
||||
|
||||
@last_archived.setter
|
||||
@@ -337,14 +331,17 @@ class Session:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return recent replayable messages for LLM input.
|
||||
|
||||
A positive ``max_messages`` applies an explicit caller-owned count
|
||||
limit. The normal model path relies on ``max_tokens`` instead.
|
||||
A committed in-turn checkpoint replaces its old prefix with the stored
|
||||
summary and resumes replay at a hidden continuation marker. A positive
|
||||
``max_messages`` applies an additional caller-owned count limit.
|
||||
"""
|
||||
replay_start = self.last_archived
|
||||
if replay_start:
|
||||
# ``last_archived`` is archive progress, not a replay boundary.
|
||||
# Keep a small raw suffix for continuity, extending back to the user
|
||||
# that started an assistant/tool sequence when necessary.
|
||||
resumes_from_checkpoint = (
|
||||
replay_start < len(self.messages)
|
||||
and is_hidden_history_message(self.messages[replay_start])
|
||||
and self.messages[replay_start].get("content") == SUMMARY_CONTINUATION_TEXT
|
||||
)
|
||||
if replay_start and not resumes_from_checkpoint:
|
||||
recent_start = recent_message_start_index(
|
||||
self.messages,
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
@@ -485,110 +482,6 @@ class Session:
|
||||
self.updated_at = datetime.now()
|
||||
self.metadata.pop("_last_summary", None)
|
||||
|
||||
def retain_recent_legal_suffix(
|
||||
self,
|
||||
max_messages: int,
|
||||
*,
|
||||
extend_to_user: bool = False,
|
||||
) -> RetentionResult:
|
||||
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
||||
|
||||
Returns a RetentionResult with dropped messages and how many of those
|
||||
were in the already-consolidated prefix. This method mutates
|
||||
self.messages and self.last_archived in place.
|
||||
"""
|
||||
if max_messages <= 0:
|
||||
dropped = list(self.messages)
|
||||
lc = self.last_archived
|
||||
self.clear()
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=min(lc, len(dropped)),
|
||||
)
|
||||
if len(self.messages) <= max_messages:
|
||||
return RetentionResult(
|
||||
dropped=[],
|
||||
already_consolidated_count=0,
|
||||
)
|
||||
|
||||
original = list(self.messages)
|
||||
before_lc = self.last_archived
|
||||
|
||||
start_idx = max(0, len(self.messages) - max_messages)
|
||||
if extend_to_user:
|
||||
recovered_user = next(
|
||||
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if recovered_user is not None:
|
||||
start_idx = recovered_user
|
||||
if start_idx > 0 and self.messages[start_idx - 1].get("_channel_delivery"):
|
||||
start_idx -= 1
|
||||
|
||||
retained = self.messages[start_idx:]
|
||||
|
||||
# Prefer starting at a user turn (or its preceding _channel_delivery) when one exists within the retained window.
|
||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
if first_user > 0 and retained[first_user - 1].get("_channel_delivery"):
|
||||
retained = retained[first_user - 1:]
|
||||
else:
|
||||
retained = retained[first_user:]
|
||||
elif not extend_to_user:
|
||||
# If the hard-capped tail is assistant/tool-only, anchor to the
|
||||
# latest user in the full session and take a capped forward window.
|
||||
latest_user = next(
|
||||
(i for i in range(len(self.messages) - 1, -1, -1)
|
||||
if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if latest_user is not None:
|
||||
retained = self.messages[latest_user: latest_user + max_messages]
|
||||
|
||||
# Mirror get_history(): avoid persisting orphan tool results at the front.
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
# Hard-cap guarantee unless the caller requested user-turn extension.
|
||||
if not extend_to_user and len(retained) > max_messages:
|
||||
retained = retained[-max_messages:]
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
# Compute actually-dropped messages using identity comparison so that
|
||||
# even when retained is a non-contiguous slice of original (the else
|
||||
# branch above), we never duplicate or lose messages.
|
||||
retained_ids = set(id(m) for m in retained)
|
||||
dropped = [m for m in original if id(m) not in retained_ids]
|
||||
|
||||
# Count how many dropped messages were in the already-consolidated
|
||||
# prefix of the original list. This cannot be a simple min() because
|
||||
# dropped may include messages from *after* the consolidated prefix
|
||||
# (e.g. in the else branch).
|
||||
already_consolidated = sum(
|
||||
1 for i, m in enumerate(original)
|
||||
if i < before_lc and id(m) not in retained_ids
|
||||
)
|
||||
|
||||
# New last_archived = count of retained messages that were inside
|
||||
# the old consolidated prefix.
|
||||
new_lc = sum(
|
||||
1 for i, m in enumerate(original)
|
||||
if i < before_lc and id(m) in retained_ids
|
||||
)
|
||||
|
||||
self.messages = retained
|
||||
self.last_archived = new_lc
|
||||
if dropped:
|
||||
self.provider_state = None
|
||||
self.updated_at = datetime.now()
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=already_consolidated,
|
||||
)
|
||||
|
||||
class SessionPayload(TypedDict):
|
||||
key: str
|
||||
created_at: str | None
|
||||
@@ -2010,7 +1903,7 @@ class SessionManager:
|
||||
user_index = 0
|
||||
found_target = False
|
||||
for message in source.messages:
|
||||
if message.get("role") == "user":
|
||||
if message.get("role") == "user" and not is_hidden_history_message(message):
|
||||
if user_index == before_user_index:
|
||||
found_target = True
|
||||
break
|
||||
|
||||
@@ -3,15 +3,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict, cast
|
||||
|
||||
SUMMARY_CONTINUATION_TEXT = (
|
||||
"Continue the active task from the working-memory checkpoint above."
|
||||
)
|
||||
|
||||
class SessionSummary(TypedDict):
|
||||
text: str
|
||||
last_active: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummaryCheckpoint:
|
||||
"""A replacement summary and the raw transcript boundary it covers."""
|
||||
|
||||
summary: str
|
||||
transcript_boundary: int
|
||||
|
||||
|
||||
def session_summary_from_metadata(
|
||||
metadata: Mapping[str, object] | None,
|
||||
*,
|
||||
|
||||
@@ -147,10 +147,10 @@ def prepare_save_boundary(ctx: TurnContext) -> None:
|
||||
if ctx.session is not None:
|
||||
clear_internal_continuation_state(ctx.session.metadata)
|
||||
|
||||
assert ctx.transcript_input is not None
|
||||
ctx.save_skip = _save_skip_for_turn(
|
||||
message_metadata=ctx.msg.metadata,
|
||||
initial_message_count=len(ctx.initial_messages),
|
||||
history_count=len(ctx.history),
|
||||
initial_message_count=ctx.transcript_input.message_count,
|
||||
input_persisted_early=ctx.input_persisted_early,
|
||||
)
|
||||
|
||||
@@ -185,7 +185,6 @@ def _save_skip_for_turn(
|
||||
*,
|
||||
message_metadata: Mapping[str, Any] | None,
|
||||
initial_message_count: int,
|
||||
history_count: int,
|
||||
input_persisted_early: bool,
|
||||
) -> int:
|
||||
"""Return the persisted-message append boundary for this turn."""
|
||||
@@ -193,10 +192,7 @@ def _save_skip_for_turn(
|
||||
return initial_message_count
|
||||
if internal_continuation_inbound(message_metadata):
|
||||
return initial_message_count
|
||||
# build_messages may merge the current message into a same-role history tail.
|
||||
# Runner-appended messages start at initial_message_count in either shape.
|
||||
has_standalone_current = initial_message_count > 1 + history_count
|
||||
if has_standalone_current and not input_persisted_early:
|
||||
if not input_persisted_early:
|
||||
return initial_message_count - 1
|
||||
return initial_message_count
|
||||
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
Create a memory overview for only the final {{ archive_count }} conversation messages immediately before this instruction. Earlier messages are context for resolving references; do not summarize them again.
|
||||
Create a compact replacement checkpoint for this session.
|
||||
|
||||
Use [skip] unless a fact meets all SNIP criteria:
|
||||
- Signal: would the user need to repeat this if forgotten?
|
||||
- Novel: not just a restatement of another fact in this same conversation chunk
|
||||
- Important: prevents rework or captures preferences / rules
|
||||
- Persistent: still relevant after 2 weeks
|
||||
When `[Archived Context Summary]` appears in the system prompt, update that previous checkpoint to reflect the current conversation state.
|
||||
|
||||
Also preserve a compact working-state handoff even when it is not Persistent: the active objective, current status, completed steps, unresolved blockers, next action, and exact identifiers needed to continue without rework. Mark these facts [ephemeral].
|
||||
## Merge rules
|
||||
|
||||
Format each fact as:
|
||||
- [mark] fact content
|
||||
- Use the latest correction or decision as the current version of a fact, and merge duplicates.
|
||||
- Preserve exact names, identifiers, paths, commands, decisions, results, and unresolved blockers when they are needed to continue the session.
|
||||
- Retain a fact already present in long-term memory when it is needed for session continuity.
|
||||
|
||||
Marks (choose the best match):
|
||||
- [permanent] Core preferences, personal traits, habits — never becomes stale
|
||||
- [durable] Technical discoveries, project knowledge, config details — valid for months
|
||||
- [ephemeral] Active task state, temporary decisions — may change in weeks
|
||||
- [correction] Correction to a previous memory — state what changed
|
||||
- [skip] Conversational filler, code/source facts derivable from the repo, or audit-only breadcrumbs
|
||||
## What to retain
|
||||
|
||||
Priority: user corrections and preferences > solutions > decisions > events > environment facts.
|
||||
Always retain a compact working-state handoff:
|
||||
- active objective
|
||||
- current status
|
||||
- completed results that constrain later work
|
||||
- unresolved blockers
|
||||
- next action
|
||||
- exact identifiers needed for that action
|
||||
|
||||
Do not output facts already present in the system prompt's Recent History.
|
||||
Mark working-state facts `[ephemeral]`.
|
||||
|
||||
Do not mark something [skip] merely because it might already exist in long-term memory.
|
||||
For other facts, retain a candidate only when it meets all four SNIP criteria:
|
||||
- Signal: remembering it saves the user from repeating it
|
||||
- Novel: it adds a distinct fact to this checkpoint
|
||||
- Important: losing it would cause rework or discard a preference or rule
|
||||
- Persistent: it is expected to remain useful for at least two weeks
|
||||
|
||||
Return only formatted fact lines, or `(nothing)` if nothing noteworthy happened.
|
||||
Assign each retained fact its best current mark:
|
||||
- `[permanent]` for core preferences, personal traits, and habits that remain relevant indefinitely
|
||||
- `[durable]` for technical discoveries, project knowledge, and configuration that remains valid for months
|
||||
- `[ephemeral]` for active task state and temporary decisions that may change within weeks
|
||||
- `[correction]` for the current fact that supersedes conflicting earlier long-term memory
|
||||
|
||||
When space is limited, prioritize user corrections and preferences, then solutions, decisions, events, and environment facts.
|
||||
|
||||
## Output
|
||||
|
||||
Return one concise retained fact per line in this form:
|
||||
- [mark] fact
|
||||
|
||||
Use `(nothing)` when no fact qualifies and there is no active working state.
|
||||
|
||||
@@ -99,7 +99,7 @@ For [SKILL] entries:
|
||||
- Skills are instruction sets with concrete values, commands, and examples. MEMORY.md keeps strategic context and high-level facts only.
|
||||
|
||||
## Editing
|
||||
- Current contents of SOUL.md, USER.md, and memory/MEMORY.md are embedded in this prompt under "Current Memory Files". Edit those files directly; do not rely on a remembered version of a file.
|
||||
- Current contents of SOUL.md, USER.md, and memory/MEMORY.md are provided by the agent system context. Edit those files directly; do not rely on a remembered version of a file.
|
||||
- Batch changes into as few calls as possible. Surgical edits only.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
result with its original consumer or checker when one is available.
|
||||
- Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, adds, or deletes.
|
||||
- Use `apply_patch dry_run=true` when the patch is uncertain and you want validation plus a change summary before writing.
|
||||
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`; when editing a specific numbered line, pass that exact line as `line_hint`; add `occurrence` or `expected_replacements` when ambiguity matters.
|
||||
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`.
|
||||
- Use `write_file` for new files or intentional full-file rewrites, not routine partial edits.
|
||||
- If `apply_patch` or `edit_file` fails, re-read with `force=true`, narrow the context, and try a smaller patch rather than switching to shell `sed` or `echo`.
|
||||
|
||||
|
||||
@@ -269,7 +269,11 @@ def update_model_call_order(
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
if models.update_model_call_order(config, query):
|
||||
if models.update_model_call_order(
|
||||
config,
|
||||
query,
|
||||
oauth_status=_oauth_provider_status,
|
||||
):
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
@@ -280,7 +284,10 @@ def migrate_model_configurations(
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
if models.migrate_model_configurations(config):
|
||||
if models.migrate_model_configurations(
|
||||
config,
|
||||
oauth_status=_oauth_provider_status,
|
||||
):
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ class ModelSettingsPayload(TypedDict):
|
||||
model_presets: list[dict[str, Any]]
|
||||
model_call_order: list[str]
|
||||
model_call_order_editable: bool
|
||||
model_configuration_migratable: bool
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
@@ -925,6 +926,48 @@ def _model_call_order_state(config: Config) -> tuple[list[str], bool]:
|
||||
return order, True
|
||||
|
||||
|
||||
def _legacy_model_configuration_migratable(
|
||||
config: Config,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> bool:
|
||||
"""Return whether the implicit default represents usable legacy configuration.
|
||||
|
||||
A pristine config still carries schema defaults for backwards compatibility.
|
||||
Those defaults are not user configuration and must not be materialized as a
|
||||
preset. Inline fallbacks, or a default whose matching provider is configured,
|
||||
are evidence that there is real legacy state to preserve.
|
||||
"""
|
||||
_, editable = _model_call_order_state(config)
|
||||
if editable:
|
||||
return False
|
||||
|
||||
defaults = config.agents.defaults
|
||||
if defaults.fallback_models:
|
||||
return True
|
||||
|
||||
provider_name = defaults.provider
|
||||
if provider_name == "auto":
|
||||
model_prefix = defaults.model.split("/", 1)[0] if "/" in defaults.model else ""
|
||||
if model_prefix and resolve_settings_provider(config, model_prefix) is not None:
|
||||
provider_name = model_prefix
|
||||
else:
|
||||
provider_name = (
|
||||
config.get_provider_name(
|
||||
defaults.model,
|
||||
preset=config.resolve_default_preset(),
|
||||
)
|
||||
or ""
|
||||
)
|
||||
if not provider_name or provider_name == "auto":
|
||||
return False
|
||||
|
||||
resolved_provider = resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
return False
|
||||
spec, _, provider_config = resolved_provider
|
||||
return provider_configured_for_settings(spec, provider_config, oauth_status)
|
||||
|
||||
|
||||
def _validate_configured_provider(
|
||||
config: Config,
|
||||
provider: str,
|
||||
@@ -1073,6 +1116,10 @@ def model_settings_payload(
|
||||
"model_presets": model_presets,
|
||||
"model_call_order": model_call_order,
|
||||
"model_call_order_editable": model_call_order_editable,
|
||||
"model_configuration_migratable": _legacy_model_configuration_migratable(
|
||||
config,
|
||||
oauth_status,
|
||||
),
|
||||
"providers": providers,
|
||||
}
|
||||
|
||||
@@ -1153,6 +1200,10 @@ def create_model_configuration(
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider, oauth_status)
|
||||
|
||||
activate_as_primary = not config.model_presets and not _legacy_model_configuration_migratable(
|
||||
config, oauth_status
|
||||
)
|
||||
|
||||
base = config.resolve_preset()
|
||||
max_tokens = _parse_positive_int(
|
||||
query_first_alias(query, "max_tokens", "maxTokens"),
|
||||
@@ -1180,6 +1231,9 @@ def create_model_configuration(
|
||||
temperature=temperature if temperature is not None else base.temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
if activate_as_primary:
|
||||
config.agents.defaults.model_preset = name
|
||||
config.agents.defaults.fallback_models = []
|
||||
return name
|
||||
|
||||
|
||||
@@ -1258,7 +1312,12 @@ def update_model_configuration(
|
||||
return changed
|
||||
|
||||
|
||||
def update_model_call_order(config: Config, query: QueryParams) -> bool:
|
||||
def update_model_call_order(
|
||||
config: Config,
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> bool:
|
||||
raw_order = query_first_alias(query, "order", "presetNames")
|
||||
if raw_order is None:
|
||||
raise WebUISettingsError("model call order is required")
|
||||
@@ -1277,15 +1336,16 @@ def update_model_call_order(config: Config, query: QueryParams) -> bool:
|
||||
raise WebUISettingsError("model call order must contain at least one preset")
|
||||
|
||||
normalized_order = [cast(str, name).strip() for name in cast(list[object], order)]
|
||||
unknown = [name for name in normalized_order if name not in config.model_presets]
|
||||
if unknown:
|
||||
raise WebUISettingsError(f"unknown model preset: {unknown[0]}")
|
||||
|
||||
_, editable = _model_call_order_state(config)
|
||||
if not editable:
|
||||
if not editable and _legacy_model_configuration_migratable(config, oauth_status):
|
||||
raise WebUISettingsError(
|
||||
"convert the existing model configuration to presets first",
|
||||
status=409,
|
||||
)
|
||||
unknown = [name for name in normalized_order if name not in config.model_presets]
|
||||
if unknown:
|
||||
raise WebUISettingsError(f"unknown model preset: {unknown[0]}")
|
||||
|
||||
defaults = config.agents.defaults
|
||||
fallback_models: list[FallbackCandidate] = list(normalized_order[1:])
|
||||
@@ -1299,8 +1359,18 @@ def update_model_call_order(config: Config, query: QueryParams) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def migrate_model_configurations(config: Config) -> bool:
|
||||
def migrate_model_configurations(
|
||||
config: Config,
|
||||
*,
|
||||
oauth_status: OAuthStatusReader,
|
||||
) -> bool:
|
||||
"""Materialize legacy primary/inline model settings as named presets."""
|
||||
_, editable = _model_call_order_state(config)
|
||||
if editable:
|
||||
return False
|
||||
if not _legacy_model_configuration_migratable(config, oauth_status):
|
||||
raise WebUISettingsError("there is no legacy model configuration to convert", status=409)
|
||||
|
||||
defaults = config.agents.defaults
|
||||
primary = config.resolve_preset()
|
||||
created: list[str] = []
|
||||
|
||||
@@ -114,7 +114,6 @@ def system_settings_payload(
|
||||
"heartbeat": {
|
||||
"enabled": config.gateway.heartbeat.enabled,
|
||||
"interval_s": config.gateway.heartbeat.interval_s,
|
||||
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||
},
|
||||
"dream": {
|
||||
"schedule": defaults.dream.describe_schedule(),
|
||||
|
||||
@@ -357,3 +357,7 @@ class WebUIWorkspaceController:
|
||||
self._draft_scopes.move_to_end(session_key)
|
||||
while len(self._draft_scopes) > _MAX_DRAFT_SCOPES:
|
||||
self._draft_scopes.popitem(last=False)
|
||||
|
||||
def discard_draft_scope(self, session_key: str) -> bool:
|
||||
"""Discard the staged scope for a chat that has not persisted yet."""
|
||||
return self._draft_scopes.pop(session_key, None) is not None
|
||||
|
||||
@@ -952,9 +952,12 @@ class GatewayHTTPHandler:
|
||||
self.local_trigger_store.delete(job.id)
|
||||
elif self.cron_service is not None:
|
||||
self.cron_service.remove_job(job.id)
|
||||
draft_deleted = self.workspaces.discard_draft_scope(decoded_key)
|
||||
session_deleted = self.session_manager.delete_session(decoded_key)
|
||||
transcript_deleted = delete_webui_thread(decoded_key)
|
||||
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
||||
return _http_json_response(
|
||||
{"deleted": bool(draft_deleted or session_deleted or transcript_deleted)}
|
||||
)
|
||||
|
||||
# -- Automation routes --------------------------------------------------
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
@@ -148,7 +149,10 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
TranscriptInput(
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
current_message=None,
|
||||
),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
|
||||
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -229,36 +228,6 @@ class TestAgentLoopTTLParam:
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=0)
|
||||
assert loop.auto_compact._ttl == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_reads_history_with_token_budget(self, tmp_path):
|
||||
"""_process_message should pass an auto-derived token budget to get_history."""
|
||||
loop = _make_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.get_history = MagicMock(return_value=[])
|
||||
loop.context.build_messages = MagicMock(return_value=[])
|
||||
loop._run_agent_loop = AsyncMock(
|
||||
return_value=AgentRunResult(
|
||||
final_content="ok",
|
||||
messages=[],
|
||||
stop_reason="stop",
|
||||
)
|
||||
)
|
||||
loop._save_turn = MagicMock()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content="hello",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
session.get_history.assert_called_once()
|
||||
kwargs = session.get_history.call_args.kwargs
|
||||
assert isinstance(kwargs.get("max_tokens"), int)
|
||||
assert kwargs["max_tokens"] > 0
|
||||
assert set(kwargs) == {"max_tokens", "extend_to_user"}
|
||||
|
||||
|
||||
class TestAutoCompact:
|
||||
"""Test the _archive method."""
|
||||
|
||||
@@ -1302,9 +1271,9 @@ class TestSummaryPersistence:
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
|
||||
# Simulate /new command
|
||||
session.clear()
|
||||
loop.sessions.save(session)
|
||||
loop.sessions.invalidate(session.key)
|
||||
reloaded.clear()
|
||||
loop.sessions.save(reloaded)
|
||||
loop.sessions.invalidate(reloaded.key)
|
||||
|
||||
# After /new, metadata should no longer contain _last_summary
|
||||
fresh = loop.sessions.get_or_create("cli:test")
|
||||
|
||||
+323
-302
@@ -1,4 +1,4 @@
|
||||
"""Tests for the lightweight Consolidator — append-only to HISTORY.md."""
|
||||
"""Tests for Memory checkpoint consolidation and history journaling."""
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import (
|
||||
_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||
_HISTORY_ENTRY_HARD_CAP,
|
||||
Consolidator,
|
||||
MemoryStore,
|
||||
)
|
||||
@@ -26,6 +26,8 @@ from nanobot.session.manager import Session
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
_ARCHIVE_PROMPT = render_template("agent/consolidator_archive.md", strip=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
@@ -53,9 +55,7 @@ def runtime(mock_provider):
|
||||
def consolidator(store):
|
||||
sessions = MagicMock()
|
||||
sessions.save = MagicMock()
|
||||
# When maybe_consolidate_by_tokens refreshes the session reference via
|
||||
# get_or_create(session.key), it should get back the same object the test
|
||||
# passed in. Store sessions by key so the lookup is transparent.
|
||||
# Store sessions by key so refreshes observe the same test object.
|
||||
_session_cache: dict[str, MagicMock] = {}
|
||||
sessions.get_or_create = MagicMock(side_effect=lambda key: _session_cache.get(key, MagicMock()))
|
||||
sessions._session_cache = _session_cache
|
||||
@@ -91,26 +91,110 @@ def _provider_state() -> ProviderConversationState:
|
||||
|
||||
|
||||
def _build_test_messages(**kwargs):
|
||||
return [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
system = "system prompt"
|
||||
session_summary = kwargs.get("session_summary")
|
||||
if session_summary:
|
||||
system += f"\n\n[Archived Context Summary]\n{session_summary['text']}"
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
*kwargs["history"],
|
||||
{"role": "user", "content": kwargs["current_message"]},
|
||||
]
|
||||
if kwargs["current_message"] is not None:
|
||||
messages.append({"role": "user", "content": kwargs["current_message"]})
|
||||
return messages
|
||||
|
||||
|
||||
async def _archive(consolidator, messages, runtime, *, session_key="test:session"):
|
||||
return await consolidator.archive(
|
||||
async def _archive(
|
||||
consolidator,
|
||||
messages,
|
||||
runtime,
|
||||
*,
|
||||
session_key="test:session",
|
||||
previous_summary=None,
|
||||
):
|
||||
return await consolidator.archiver.archive(
|
||||
messages,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
request_messages=_build_test_messages(
|
||||
history=messages,
|
||||
current_message="consolidate",
|
||||
),
|
||||
history=[
|
||||
{"role": "system", "content": "system prompt"},
|
||||
*messages,
|
||||
],
|
||||
request_tools=[],
|
||||
previous_summary=previous_summary,
|
||||
)
|
||||
|
||||
|
||||
class TestTurnTranscriptSummary:
|
||||
async def test_uses_exact_accepted_prefix_and_existing_archiver(
|
||||
self,
|
||||
consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
accepted = [
|
||||
{"role": "system", "content": "stable system"},
|
||||
{"role": "user", "content": "accepted history"},
|
||||
]
|
||||
tools = [{"type": "function", "function": {"name": "inspect"}}]
|
||||
mock_provider.chat_with_retry.return_value = LLMResponse(
|
||||
content="replacement checkpoint",
|
||||
)
|
||||
|
||||
summary = await consolidator.summarize_transcript(
|
||||
accepted,
|
||||
"previous checkpoint",
|
||||
runtime=runtime,
|
||||
session_key="test:turn",
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
assert summary == "replacement checkpoint"
|
||||
call = mock_provider.chat_with_retry.await_args.kwargs
|
||||
assert call["messages"][:-1] == accepted
|
||||
assert call["messages"][-1]["role"] == "user"
|
||||
assert "SNIP" in call["messages"][-1]["content"]
|
||||
assert call["tools"] == tools
|
||||
|
||||
async def test_native_compaction_appends_only_archive_prompt(
|
||||
self,
|
||||
consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
accepted = [
|
||||
{"role": "system", "content": "stable system"},
|
||||
{"role": "user", "content": "raw history must not be replayed"},
|
||||
]
|
||||
state = _provider_state()
|
||||
mock_provider.can_resume_conversation_state.return_value = True
|
||||
mock_provider.chat_with_retry.return_value = LLMResponse(
|
||||
content="replacement checkpoint",
|
||||
)
|
||||
|
||||
summary = await consolidator.summarize_provider_compaction(
|
||||
state,
|
||||
accepted,
|
||||
"previous checkpoint",
|
||||
runtime=runtime,
|
||||
session_key="test:turn",
|
||||
tools=[{"type": "function", "function": {"name": "inspect"}}],
|
||||
)
|
||||
|
||||
assert summary == "replacement checkpoint"
|
||||
call = mock_provider.chat_with_retry.await_args.kwargs
|
||||
assert call["messages"][0] == accepted[0]
|
||||
assert call["messages"][-1]["content"] == _ARCHIVE_PROMPT
|
||||
assert accepted[1] not in call["messages"]
|
||||
assert call["tools"] == []
|
||||
provider_context = call["provider_context"]
|
||||
assert provider_context.conversation_state is not None
|
||||
assert provider_context.conversation_state.payload == state.payload
|
||||
assert provider_context.conversation_state.pending_messages == [
|
||||
call["messages"][-1],
|
||||
]
|
||||
|
||||
|
||||
class TestConsolidatorSummarize:
|
||||
def test_format_messages_keeps_media_only_user_turn(self):
|
||||
path = "/home/user/.nanobot/media/websocket/clip.mp4"
|
||||
@@ -201,7 +285,9 @@ class TestConsolidatorSummarize:
|
||||
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
result = await _archive(consolidator, messages, runtime)
|
||||
assert result is None # no summary on raw dump fallback
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
assert "hello" in result
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert "[RAW]" in entries[0]["content"]
|
||||
@@ -226,23 +312,51 @@ class TestConsolidatorSummarize:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert entries[0]["session_key"] == "slack:chat-2"
|
||||
|
||||
async def test_raw_fallback_represents_previous_checkpoint_and_new_chunk(
|
||||
self,
|
||||
consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
runtime = replace(runtime, generation=GenerationSettings(max_tokens=96))
|
||||
mock_provider.chat_with_retry.side_effect = RuntimeError("API error")
|
||||
|
||||
result = await _archive(
|
||||
consolidator,
|
||||
[{"role": "user", "content": "NEW_MARKER " + "new " * 200}],
|
||||
runtime,
|
||||
previous_summary="OLD_MARKER " + "old " * 200,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "[Previous archived context]" in result
|
||||
assert "OLD_MARKER" in result
|
||||
assert "[Newly archived raw context]" in result
|
||||
assert "NEW_MARKER" in result
|
||||
assert "... (truncated)" in result
|
||||
|
||||
async def test_summarize_skips_empty_messages(self, consolidator, runtime):
|
||||
result = await _archive(consolidator, [], runtime)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConsolidatorPromptContract:
|
||||
def test_archive_prompt_preserves_working_state_with_memory_facts(self):
|
||||
prompt = render_template("agent/consolidator_archive.md", strip=True, archive_count=4)
|
||||
def test_archive_prompt_requests_a_cumulative_replacement_checkpoint(self):
|
||||
prompt = _ARCHIVE_PROMPT
|
||||
|
||||
for section in ("## Merge rules", "## What to retain", "## Output"):
|
||||
assert section in prompt
|
||||
assert "replacement checkpoint" in prompt
|
||||
assert "[Archived Context Summary]" in prompt
|
||||
assert "current conversation state" in prompt
|
||||
assert "SNIP" in prompt
|
||||
assert "final 4 conversation messages" in prompt
|
||||
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]", "[skip]"):
|
||||
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]"):
|
||||
assert mark in prompt
|
||||
assert "working-state handoff" in prompt
|
||||
assert "exact identifiers needed to continue without rework" in prompt
|
||||
assert "Do not output facts already present in the system prompt's Recent History" in prompt
|
||||
assert "Do not mark something [skip] merely because it might already exist" in prompt
|
||||
assert "- [mark] fact" in prompt
|
||||
assert "[skip]" not in prompt
|
||||
assert "(nothing)" in prompt
|
||||
assert "history.jsonl" not in prompt
|
||||
|
||||
|
||||
class TestConsolidatorArchiveErrorHandling:
|
||||
@@ -272,7 +386,8 @@ class TestConsolidatorArchiveErrorHandling:
|
||||
{"role": "assistant", "content": "Done, fixed the race condition."},
|
||||
]
|
||||
result = await _archive(consolidator, messages, runtime)
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert "[RAW]" in entries[0]["content"]
|
||||
@@ -338,32 +453,7 @@ class TestConsolidatorArchiveErrorHandling:
|
||||
consolidator.store.raw_archive.assert_not_called()
|
||||
|
||||
|
||||
class TestConsolidatorTokenBudget:
|
||||
async def test_prompt_below_threshold_does_not_consolidate(
|
||||
self, consolidator, runtime
|
||||
):
|
||||
"""No consolidation when tokens are within budget."""
|
||||
session = MagicMock()
|
||||
session.last_archived = 0
|
||||
session.messages = [{"role": "user", "content": "hi"}]
|
||||
session.key = "test:key"
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
|
||||
consolidator.archive_session = AsyncMock(return_value=True)
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
consolidator.archive_session.assert_not_called()
|
||||
|
||||
async def test_token_estimation_failure_propagates(self, consolidator, runtime):
|
||||
session = Session(key="test:estimate-failure")
|
||||
session.add_message("user", "hello")
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=RuntimeError("counter failed")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="counter failed"):
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
class TestConsolidatorPromptEstimate:
|
||||
async def test_estimate_uses_full_unarchived_tail(self, consolidator, runtime):
|
||||
"""Consolidation pressure must account for the full unarchived tail."""
|
||||
session = Session(key="test:full-tail")
|
||||
@@ -402,130 +492,6 @@ class TestConsolidatorTokenBudget:
|
||||
assert len(captured["history"]) == 8
|
||||
assert captured["history"][0]["content"] == "msg-2"
|
||||
|
||||
async def test_token_overflow_appends_prompt_to_replay_prefix(
|
||||
self,
|
||||
consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = Session(key="test:token-prefix")
|
||||
session.provider_state = _provider_state()
|
||||
session.messages = [
|
||||
{
|
||||
"role": "user" if i in {0, 50, 61} else "assistant",
|
||||
"content": f"m{i}",
|
||||
}
|
||||
for i in range(70)
|
||||
]
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=50)
|
||||
consolidator.archiver._build_messages = MagicMock(side_effect=_build_test_messages)
|
||||
mock_provider.estimate_prompt_tokens.return_value = (100, "test-counter")
|
||||
mock_provider.chat_with_retry.return_value = LLMResponse(
|
||||
content="Token overflow summary.",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
request = mock_provider.chat_with_retry.await_args.kwargs
|
||||
assert [message["content"] for message in request["messages"][1:-1]] == [
|
||||
f"m{i}" for i in range(50)
|
||||
]
|
||||
assert "final 50 conversation messages" in request["messages"][-1]["content"]
|
||||
assert request["tools"] == []
|
||||
assert request["tool_choice"] == "none"
|
||||
assert session.last_archived == 50
|
||||
assert session.provider_state == _provider_state()
|
||||
|
||||
async def test_raw_archive_fallback_advances_archive_watermark(
|
||||
self, consolidator, runtime
|
||||
):
|
||||
"""When archive() falls back to raw-archive (LLM failed), the cursor
|
||||
must still advance. Otherwise the same chunk gets raw-archived again
|
||||
on every subsequent maybe_consolidate_by_tokens() call, spamming
|
||||
duplicate [RAW] entries into history.jsonl."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = Session(key="test:key")
|
||||
session.provider_state = _provider_state()
|
||||
session.messages = [
|
||||
{"role": "user" if i in {0, 50} else "assistant", "content": f"m{i}"}
|
||||
for i in range(70)
|
||||
]
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
# LLM consolidation fails after raw_archive fires.
|
||||
consolidator.archive_session = AsyncMock(return_value=None)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
consolidator.archive_session.assert_awaited_once()
|
||||
# The chunk is considered "materialized" (as a raw-archive breadcrumb),
|
||||
# so the archive watermark must have moved past it without touching
|
||||
# the provider-owned continuation state.
|
||||
assert session.last_archived == 50
|
||||
assert session.provider_state == _provider_state()
|
||||
|
||||
async def test_raw_archive_fallback_breaks_round_loop(
|
||||
self, consolidator, runtime
|
||||
):
|
||||
"""A degraded LLM should not trigger more archive() calls within the
|
||||
same maybe_consolidate_by_tokens invocation — bail after one fallback."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_archived = 0
|
||||
session.key = "test:key"
|
||||
session.messages = [
|
||||
{"role": "user" if i in {0, 20, 40, 60} else "assistant", "content": f"m{i}"}
|
||||
for i in range(70)
|
||||
]
|
||||
session.metadata = {}
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
# Keep estimates high so the loop would otherwise run multiple rounds.
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(1200, "tiktoken")
|
||||
)
|
||||
consolidator.archive_session = AsyncMock(return_value=None)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
# The fixed policy archives at most one prefix per call.
|
||||
assert consolidator.archive_session.await_count == 1
|
||||
|
||||
async def test_boundary_respected_when_no_intermediate_user_turn(
|
||||
self, consolidator, runtime
|
||||
):
|
||||
"""When boundary points past a long tool chain, the full chunk is archived."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_archived = 0
|
||||
session.key = "test:key"
|
||||
session.messages = [
|
||||
{
|
||||
"role": "user" if i in {0, 61} else "assistant",
|
||||
"content": f"m{i}",
|
||||
}
|
||||
for i in range(70)
|
||||
]
|
||||
consolidator.sessions._session_cache[session.key] = session
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
consolidator.archive_session = AsyncMock(return_value=True)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
consolidator.archive_session.assert_awaited_once()
|
||||
# The fixed recent tail expands backward to the user at idx=61.
|
||||
assert session.last_archived == 61
|
||||
|
||||
|
||||
class TestCompactIdleSession:
|
||||
"""Idle compaction tests."""
|
||||
|
||||
@@ -613,27 +579,62 @@ class TestCompactIdleSession:
|
||||
assert reloaded.last_archived == 2
|
||||
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_compaction_with_no_new_messages_is_noop(
|
||||
self, real_consolidator, mock_provider, store, runtime
|
||||
):
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:archived-idle")
|
||||
session.add_message("user", "already archived")
|
||||
session.add_message("assistant", "old answer")
|
||||
session.last_archived = 2
|
||||
sessions.save(session)
|
||||
sessions.invalidate("cli:archived-idle")
|
||||
|
||||
result = await real_consolidator.compact_idle_session(
|
||||
"cli:archived-idle",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result == ""
|
||||
mock_provider.chat_with_retry.assert_not_awaited()
|
||||
reloaded = sessions.get_or_create("cli:archived-idle")
|
||||
assert reloaded.last_archived == 2
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
assert store.read_unprocessed_history(since_cursor=0) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_messages_advance_existing_archive_progress(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
):
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary.", finish_reason="stop"
|
||||
)
|
||||
mock_provider.chat_with_retry.side_effect = [
|
||||
MagicMock(content="First replacement checkpoint.", finish_reason="stop"),
|
||||
MagicMock(content="Second replacement checkpoint.", finish_reason="stop"),
|
||||
]
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:incremental")
|
||||
session.add_message("user", "first user")
|
||||
session.add_message("assistant", "first assistant")
|
||||
sessions.save(session)
|
||||
|
||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
||||
first = await real_consolidator.compact_idle_session(
|
||||
"cli:incremental",
|
||||
runtime=runtime,
|
||||
)
|
||||
current = sessions.get_or_create("cli:incremental")
|
||||
current.add_message("user", "second user")
|
||||
current.add_message("assistant", "second assistant")
|
||||
sessions.save(current)
|
||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
||||
second = await real_consolidator.compact_idle_session(
|
||||
"cli:incremental",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert first == "First replacement checkpoint."
|
||||
assert second == "Second replacement checkpoint."
|
||||
assert mock_provider.chat_with_retry.await_count == 2
|
||||
latest_build = real_consolidator.archiver._build_messages.call_args_list[-1].kwargs
|
||||
assert latest_build["session_summary"]["text"] == "First replacement checkpoint."
|
||||
latest_messages = mock_provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
||||
assert [message["content"] for message in latest_messages[1:5]] == [
|
||||
"first user",
|
||||
@@ -641,8 +642,91 @@ class TestCompactIdleSession:
|
||||
"second user",
|
||||
"second assistant",
|
||||
]
|
||||
assert "final 2 conversation messages" in latest_messages[-1]["content"]
|
||||
assert sessions.get_or_create("cli:incremental").last_archived == 4
|
||||
assert latest_messages[-1]["content"] == _ARCHIVE_PROMPT
|
||||
sessions.invalidate("cli:incremental")
|
||||
reloaded = sessions.get_or_create("cli:incremental")
|
||||
assert reloaded.last_archived == 4
|
||||
assert reloaded.metadata["_last_summary"]["text"] == second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_fallback_preserves_previous_checkpoint_and_new_chunk(
|
||||
self,
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
store,
|
||||
runtime,
|
||||
):
|
||||
mock_provider.chat_with_retry.side_effect = [
|
||||
LLMResponse(content="Earlier durable checkpoint.", finish_reason="stop"),
|
||||
RuntimeError("LLM unavailable"),
|
||||
]
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:cumulative-fallback")
|
||||
session.add_message("user", "first user")
|
||||
session.add_message("assistant", "first answer")
|
||||
sessions.save(session)
|
||||
|
||||
await real_consolidator.compact_idle_session(
|
||||
"cli:cumulative-fallback",
|
||||
runtime=runtime,
|
||||
)
|
||||
current = sessions.get_or_create("cli:cumulative-fallback")
|
||||
current.add_message("user", "second user")
|
||||
current.add_message("assistant", "newest working state")
|
||||
sessions.save(current)
|
||||
|
||||
fallback = await real_consolidator.compact_idle_session(
|
||||
"cli:cumulative-fallback",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert fallback is not None
|
||||
assert "[Previous archived context]" in fallback
|
||||
assert "Earlier durable checkpoint." in fallback
|
||||
assert "[Newly archived raw context]" in fallback
|
||||
assert "newest working state" in fallback
|
||||
entries = store.read_unprocessed_history(0)
|
||||
assert entries[0]["content"] == "Earlier durable checkpoint."
|
||||
assert entries[1]["content"].startswith("[RAW] 2 messages")
|
||||
sessions.invalidate("cli:cumulative-fallback")
|
||||
reloaded = sessions.get_or_create("cli:cumulative-fallback")
|
||||
assert reloaded.metadata["_last_summary"]["text"] == fallback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_keeps_previous_replacement_checkpoint(
|
||||
self,
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
mock_provider.chat_with_retry.side_effect = [
|
||||
LLMResponse(content="Existing checkpoint.", finish_reason="stop"),
|
||||
LLMResponse(content="(nothing)", finish_reason="stop"),
|
||||
]
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:nothing-after-summary")
|
||||
session.add_message("user", "important first turn")
|
||||
session.add_message("assistant", "important result")
|
||||
sessions.save(session)
|
||||
await real_consolidator.compact_idle_session(
|
||||
"cli:nothing-after-summary",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
current = sessions.get_or_create("cli:nothing-after-summary")
|
||||
current.add_message("user", "thanks")
|
||||
current.add_message("assistant", "you're welcome")
|
||||
sessions.save(current)
|
||||
result = await real_consolidator.compact_idle_session(
|
||||
"cli:nothing-after-summary",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result == "(nothing)"
|
||||
sessions.invalidate("cli:nothing-after-summary")
|
||||
reloaded = sessions.get_or_create("cli:nothing-after-summary")
|
||||
assert reloaded.last_archived == 4
|
||||
assert reloaded.metadata["_last_summary"]["text"] == "Existing checkpoint."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_append_remains_unarchived(
|
||||
@@ -792,11 +876,16 @@ class TestCompactIdleSession:
|
||||
result = await real_consolidator.compact_idle_session(
|
||||
"cli:nothing", runtime=runtime, max_suffix=4
|
||||
)
|
||||
second = await real_consolidator.compact_idle_session(
|
||||
"cli:nothing", runtime=runtime, max_suffix=4
|
||||
)
|
||||
assert result == "(nothing)"
|
||||
assert second == ""
|
||||
|
||||
reloaded = sessions.get_or_create("cli:nothing")
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
assert real_consolidator.store.read_unprocessed_history(0) == []
|
||||
mock_provider.chat_with_retry.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_failure_preserves_history_but_advances_replay_boundary(
|
||||
@@ -813,7 +902,8 @@ class TestCompactIdleSession:
|
||||
result = await real_consolidator.compact_idle_session(
|
||||
"cli:fail", runtime=runtime, max_suffix=4
|
||||
)
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
|
||||
# raw_archive should have been called (history.jsonl gets an entry)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
@@ -823,6 +913,7 @@ class TestCompactIdleSession:
|
||||
assert len(reloaded.messages) == 20
|
||||
assert reloaded.messages[0]["content"] == "u0"
|
||||
assert reloaded.last_archived == 20
|
||||
assert reloaded.metadata["_last_summary"]["text"] == result
|
||||
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
||||
"u6",
|
||||
"a6",
|
||||
@@ -863,11 +954,10 @@ class TestCompactIdleSession:
|
||||
archived_call = mock_provider.chat_with_retry.call_args
|
||||
sent_messages = archived_call.kwargs["messages"]
|
||||
sent_content = [message.get("content") for message in sent_messages]
|
||||
# The ordinary replay prefix contributes recent context, while the
|
||||
# temporary instruction limits the new overview to the unarchived tail.
|
||||
# The replacement overview covers all model-visible conversation context.
|
||||
assert "u0" not in sent_content
|
||||
assert "u26" in sent_content
|
||||
assert "final 10 conversation messages" in sent_messages[-1]["content"]
|
||||
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_archive_keeps_extended_legal_replay_suffix(
|
||||
@@ -956,9 +1046,9 @@ class TestCompactIdleSession:
|
||||
"user",
|
||||
]
|
||||
assert sent_messages[2]["tool_calls"][0]["id"] == "call-1"
|
||||
assert "final 4 conversation messages" in sent_messages[-1]["content"]
|
||||
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
|
||||
assert call["tools"] == tools
|
||||
assert call["tool_choice"] == "none"
|
||||
assert "tool_choice" not in call
|
||||
|
||||
reloaded = sessions.get_or_create("cli:tool-history")
|
||||
assert len(reloaded.messages) == 4
|
||||
@@ -996,7 +1086,8 @@ class TestCompactIdleSession:
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["content"].startswith("[RAW] ")
|
||||
@@ -1026,7 +1117,8 @@ class TestCompactIdleSession:
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["content"].startswith("[RAW] ")
|
||||
@@ -1052,7 +1144,8 @@ class TestCompactIdleSession:
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert "[RAW]" in result
|
||||
mock_provider.chat_with_retry.assert_not_awaited()
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
@@ -1060,7 +1153,7 @@ class TestCompactIdleSession:
|
||||
assert sessions.get_or_create("sdk:oversized").last_archived == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incremental_scope_counts_only_model_visible_messages(
|
||||
async def test_archive_context_contains_only_model_visible_messages(
|
||||
self,
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
@@ -1093,7 +1186,7 @@ class TestCompactIdleSession:
|
||||
"new user",
|
||||
"new answer",
|
||||
]
|
||||
assert "final 2 conversation messages" in sent[-1]["content"]
|
||||
assert sent[-1]["content"] == _ARCHIVE_PROMPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_real_prefix_for_unified_session_workspace(
|
||||
@@ -1126,8 +1219,6 @@ class TestCompactIdleSession:
|
||||
current_message="next project question",
|
||||
channel="websocket",
|
||||
workspace=project,
|
||||
session_key=session.key,
|
||||
unified_session=True,
|
||||
)
|
||||
|
||||
await loop.consolidator.compact_idle_session(
|
||||
@@ -1137,7 +1228,7 @@ class TestCompactIdleSession:
|
||||
|
||||
sent_messages = runtime.provider.chat_with_retry.call_args.kwargs["messages"]
|
||||
assert sent_messages[:-1] == ordinary_messages[:-1]
|
||||
assert "final 2 conversation messages" in sent_messages[-1]["content"]
|
||||
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
|
||||
system = sent_messages[0]["content"]
|
||||
assert "PROJECT_WORKSPACE_MARKER" in system
|
||||
assert "GLOBAL_WORKSPACE_MARKER" not in system
|
||||
@@ -1182,110 +1273,6 @@ class TestCompactIdleSession:
|
||||
assert not lock.locked()
|
||||
|
||||
|
||||
class TestConsolidatorSessionRefresh:
|
||||
"""Background consolidation must detect stale session references."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reloads_before_empty_session_guard(self, tmp_path):
|
||||
"""A stale empty reference must not skip a non-empty cached session."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=MagicMock(content="summary", finish_reason="stop")
|
||||
)
|
||||
provider.generation = GenerationSettings(max_tokens=4096)
|
||||
provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
|
||||
runtime = LLMRuntime.capture(
|
||||
provider,
|
||||
"test-model",
|
||||
context_window_tokens=128_000,
|
||||
)
|
||||
sessions = SessionManager(tmp_path)
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
sessions=sessions,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
)
|
||||
|
||||
fresh = sessions.get_or_create("cli:test")
|
||||
fresh.add_message("user", "fresh message")
|
||||
sessions.save(fresh)
|
||||
stale_empty = Session(key="cli:test")
|
||||
|
||||
seen: dict[str, Session] = {}
|
||||
|
||||
def estimate(session: Session, *, runtime):
|
||||
seen["session"] = session
|
||||
return 10, "test"
|
||||
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(side_effect=estimate)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(
|
||||
stale_empty,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert seen["session"] is fresh
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reloads_stale_session_after_compact(self, tmp_path):
|
||||
"""After compact_idle_session replaces the session, a concurrent
|
||||
maybe_consolidate_by_tokens with the old reference should use the
|
||||
fresh session from cache instead of overwriting."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=MagicMock(content="summary", finish_reason="stop")
|
||||
)
|
||||
provider.generation = GenerationSettings(max_tokens=4096)
|
||||
provider.estimate_prompt_tokens = MagicMock(return_value=(10, "test"))
|
||||
runtime = LLMRuntime.capture(
|
||||
provider,
|
||||
"test-model",
|
||||
context_window_tokens=128_000,
|
||||
)
|
||||
sessions = SessionManager(tmp_path)
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
sessions=sessions,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
)
|
||||
|
||||
# Populate session with many messages
|
||||
session = sessions.get_or_create("cli:test")
|
||||
for i in range(20):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.add_message("assistant", f"a{i}")
|
||||
sessions.save(session)
|
||||
|
||||
# Simulate: background consolidation captures old reference
|
||||
old_ref = session
|
||||
|
||||
await consolidator.compact_idle_session(
|
||||
"cli:test",
|
||||
runtime=runtime,
|
||||
max_suffix=8,
|
||||
)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(
|
||||
old_ref,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
session_after = sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 40
|
||||
assert session_after.last_archived == 40
|
||||
assert len(session_after.get_history(max_messages=40)) == 8
|
||||
|
||||
|
||||
class TestRawArchiveTruncation:
|
||||
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
|
||||
|
||||
@@ -1307,6 +1294,21 @@ class TestRawArchiveTruncation:
|
||||
assert len(entries) == 1
|
||||
assert "hello" in entries[0]["content"]
|
||||
|
||||
def test_raw_archive_returns_the_sanitized_persisted_checkpoint(self, store):
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "<think>PRIVATE_REASONING</think>visible result",
|
||||
}
|
||||
]
|
||||
|
||||
checkpoint = store.raw_archive(messages, session_key="cli:test")
|
||||
|
||||
persisted = store.read_unprocessed_history(since_cursor=0)[0]["content"]
|
||||
assert checkpoint == persisted
|
||||
assert "PRIVATE_REASONING" not in checkpoint
|
||||
assert "visible result" in checkpoint
|
||||
|
||||
def test_raw_archive_excludes_model_only_runtime_context(self, store):
|
||||
content, marker = append_runtime_context(
|
||||
"ship the feature",
|
||||
@@ -1338,21 +1340,40 @@ class TestRawArchiveTruncation:
|
||||
|
||||
|
||||
class TestArchivePersistence:
|
||||
async def test_oversized_summary_is_capped_before_append(
|
||||
async def test_archive_returns_the_sanitized_persisted_summary(
|
||||
self, consolidator, mock_provider, store, runtime
|
||||
):
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="<think>PRIVATE_REASONING</think>safe summary",
|
||||
finish_reason="stop",
|
||||
has_tool_calls=False,
|
||||
)
|
||||
|
||||
summary = await _archive(
|
||||
consolidator,
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime,
|
||||
)
|
||||
|
||||
persisted = store.read_unprocessed_history(since_cursor=0)[0]["content"]
|
||||
assert summary == persisted == "safe summary"
|
||||
|
||||
async def test_oversized_summary_uses_history_emergency_cap(
|
||||
self, consolidator, mock_provider, store, runtime
|
||||
):
|
||||
"""A pathologically large LLM summary must not land full-length in
|
||||
history.jsonl — that would re-open the #3412 bloat vector from the
|
||||
*success* path instead of the fallback path."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="S" * (_ARCHIVE_SUMMARY_MAX_CHARS * 10),
|
||||
content="S" * (_HISTORY_ENTRY_HARD_CAP * 2),
|
||||
finish_reason="stop",
|
||||
)
|
||||
await _archive(
|
||||
summary = await _archive(
|
||||
consolidator,
|
||||
[{"role": "user", "content": "hi"}],
|
||||
runtime,
|
||||
)
|
||||
|
||||
entry = store.read_unprocessed_history(since_cursor=0)[0]
|
||||
assert len(entry["content"]) <= _ARCHIVE_SUMMARY_MAX_CHARS + 50
|
||||
assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50
|
||||
assert summary == entry["content"]
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.context import ContextBuilder, TranscriptInput
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -133,10 +133,7 @@ class TestLoadBootstrapFiles:
|
||||
(project / "SOUL.md").write_text("project soul collision", encoding="utf-8")
|
||||
(project / "USER.md").write_text("project user collision", encoding="utf-8")
|
||||
|
||||
result = ContextBuilder(agent_home).build_system_prompt(
|
||||
workspace=project,
|
||||
include_memory_recent_history=False,
|
||||
)
|
||||
result = ContextBuilder(agent_home).build_system_prompt(workspace=project)
|
||||
|
||||
assert "selected project rules" in result
|
||||
assert "global project rules" not in result
|
||||
@@ -152,10 +149,7 @@ class TestLoadBootstrapFiles:
|
||||
project.mkdir()
|
||||
(agent_home / "AGENTS.md").write_text("default workspace rules", encoding="utf-8")
|
||||
|
||||
result = ContextBuilder(agent_home).build_system_prompt(
|
||||
workspace=project,
|
||||
include_memory_recent_history=False,
|
||||
)
|
||||
result = ContextBuilder(agent_home).build_system_prompt(workspace=project)
|
||||
|
||||
assert "default workspace rules" not in result
|
||||
|
||||
@@ -403,6 +397,15 @@ class TestBuildMessages:
|
||||
assert "user-only runtime context" not in messages[-1]["content"]
|
||||
assert "_meta" not in messages[-1]
|
||||
|
||||
def test_compatibility_builder_merges_system_role_without_history(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
messages = builder.build_messages([], "system event", current_role="system")
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "system"
|
||||
assert str(messages[0]["content"]).endswith("system event")
|
||||
|
||||
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
|
||||
skill_dir = tmp_path / "skills" / "review"
|
||||
skill_dir.mkdir(parents=True)
|
||||
@@ -472,6 +475,20 @@ class TestBuildMessages:
|
||||
assert "previous user message" in str(messages[1]["content"])
|
||||
assert "new message" in str(messages[1]["content"])
|
||||
|
||||
def test_structured_transcript_preserves_fresh_turn_boundary(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
transcript = TranscriptInput(
|
||||
history=[{"role": "user", "content": "previous user message"}],
|
||||
current_message="new message",
|
||||
)
|
||||
|
||||
messages = builder.build_transcript(transcript)
|
||||
|
||||
assert [message["role"] for message in messages] == ["system", "user", "user"]
|
||||
assert messages[-2]["content"] == "previous user message"
|
||||
assert messages[-1]["content"] == "new message"
|
||||
assert transcript.message_count == 3
|
||||
|
||||
def test_current_message_can_be_built_without_history_merge(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
current = builder.build_current_message(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as datetime_module
|
||||
import re
|
||||
from datetime import datetime as real_datetime
|
||||
from importlib.resources import files as pkg_files
|
||||
from pathlib import Path
|
||||
@@ -104,173 +103,6 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
|
||||
assert user_pos < context_pos, "user content must precede provider context"
|
||||
|
||||
|
||||
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
builder.memory.append_history("User asked about weather in Tokyo")
|
||||
builder.memory.append_history("Agent fetched forecast via web_search")
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" in prompt
|
||||
assert "User asked about weather in Tokyo" in prompt
|
||||
assert "Agent fetched forecast via web_search" in 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_session_summary_replaces_interleaved_recent_history_entry(tmp_path) -> None:
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
session_key = "unified:default"
|
||||
overview = "CURRENT_SESSION_OVERVIEW_MARKER"
|
||||
|
||||
builder.memory.append_history("another session event", session_key=session_key)
|
||||
builder.memory.append_history(overview, session_key=session_key)
|
||||
latest_cursor = builder.memory.append_history(
|
||||
"later telegram event",
|
||||
session_key="telegram:chat-1",
|
||||
)
|
||||
summary = {"text": overview, "last_active": "2026-08-19T10:00:00"}
|
||||
|
||||
prompt = builder.build_system_prompt(
|
||||
session_key=session_key,
|
||||
session_summary=summary,
|
||||
unified_session=True,
|
||||
)
|
||||
|
||||
assert "# Recent History" in prompt
|
||||
assert "another session event" in prompt
|
||||
assert "later telegram event" in prompt
|
||||
assert "[Archived Context Summary]" in prompt
|
||||
assert prompt.count(overview) == 1
|
||||
|
||||
builder.memory.set_last_dream_cursor(latest_cursor)
|
||||
processed_prompt = builder.build_system_prompt(
|
||||
session_key=session_key,
|
||||
session_summary=summary,
|
||||
unified_session=True,
|
||||
)
|
||||
assert "# Recent History" not in processed_prompt
|
||||
assert processed_prompt.count(overview) == 1
|
||||
|
||||
|
||||
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:
|
||||
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
for i in range(builder._MAX_RECENT_HISTORY + 20):
|
||||
builder.memory.append_history(f"entry-{i}")
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "entry-0" not in prompt
|
||||
assert "entry-19" not in prompt
|
||||
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
|
||||
|
||||
|
||||
def test_recent_history_truncated_at_max_tokens(tmp_path) -> None:
|
||||
"""Recent History section must be truncated to _MAX_HISTORY_TOKENS."""
|
||||
import tiktoken
|
||||
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
big_entry = "word " * (builder._MAX_HISTORY_TOKENS + 5_000)
|
||||
builder.memory.append_history(big_entry)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
history_section = prompt.split("# Recent History\n\n", 1)
|
||||
assert len(history_section) == 2
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
assert len(enc.encode(history_section[1])) <= builder._MAX_HISTORY_TOKENS
|
||||
|
||||
|
||||
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
|
||||
"""If Dream has consumed everything, no Recent History section should appear."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
cursor = builder.memory.append_history("already processed entry")
|
||||
builder.memory.set_last_dream_cursor(cursor)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" not in prompt
|
||||
|
||||
|
||||
def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
|
||||
"""When Dream has processed some entries, only the unprocessed ones appear."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
builder.memory.append_history("old conversation about Python")
|
||||
c2 = builder.memory.append_history("old conversation about Rust")
|
||||
builder.memory.append_history("recent question about Docker")
|
||||
builder.memory.append_history("recent question about K8s")
|
||||
|
||||
builder.memory.set_last_dream_cursor(c2)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
assert "# Recent History" in prompt
|
||||
assert "old conversation about Python" not in prompt
|
||||
assert "old conversation about Rust" not in prompt
|
||||
assert "recent question about Docker" in prompt
|
||||
assert "recent question about K8s" in prompt
|
||||
|
||||
|
||||
def test_execution_rules_in_system_prompt(tmp_path) -> None:
|
||||
"""Execution rules should appear in the system prompt via the default templates."""
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
+66
-39
@@ -62,28 +62,14 @@ class TestBuildDreamPrompt:
|
||||
prompt, _ = result
|
||||
assert "skill-creator" in prompt
|
||||
|
||||
def test_prompt_embeds_current_memory_file_contents(self, store):
|
||||
"""Dream must see the real current file contents (Tier 4) so it edits the
|
||||
files, not a stale mental model."""
|
||||
def test_prompt_does_not_duplicate_current_memory_file_contents(self, store):
|
||||
store.append_history("hello")
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
assert "## Current Memory Files" in prompt
|
||||
assert "### SOUL.md" in prompt
|
||||
assert "### USER.md" in prompt
|
||||
assert "### memory/MEMORY.md" in prompt
|
||||
# Real current contents are embedded verbatim.
|
||||
assert "Project X active" in prompt
|
||||
assert "Helpful" in prompt
|
||||
|
||||
def test_prompt_renders_missing_files_as_empty(self, tmp_path):
|
||||
store = MemoryStore(tmp_path) # no durable files written
|
||||
store.append_history("hello")
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
assert "(empty)" in prompt
|
||||
assert "## Current Memory Files" not in prompt
|
||||
assert "Project X active" not in prompt
|
||||
assert "Helpful" not in prompt
|
||||
|
||||
def test_workspace_dream_prompt_overrides_default(self, store):
|
||||
store.dream_prompt_file.parent.mkdir(parents=True)
|
||||
@@ -418,15 +404,14 @@ class TestEphemeralDirect:
|
||||
with (
|
||||
patch("nanobot.agent.loop.SessionManager"),
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub,
|
||||
patch("nanobot.agent.loop.Consolidator") as mock_consolidator_cls,
|
||||
patch("nanobot.agent.loop.Consolidator"),
|
||||
):
|
||||
mock_sub.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
mock_consolidator_cls.return_value.maybe_consolidate_by_tokens = AsyncMock()
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
context_window_tokens=32_000,
|
||||
)
|
||||
|
||||
return loop, store
|
||||
@@ -507,20 +492,6 @@ class TestEphemeralDirect:
|
||||
|
||||
assert captured.get("ephemeral") is False
|
||||
|
||||
async def test_ephemeral_skips_consolidator(self, tmp_path, _make_loop):
|
||||
"""When ephemeral=True, consolidator.maybe_consolidate_by_tokens is not called."""
|
||||
from unittest.mock import patch
|
||||
|
||||
loop, store = _make_loop
|
||||
|
||||
with patch.object(
|
||||
loop.consolidator, "maybe_consolidate_by_tokens",
|
||||
) as mock_consolidate:
|
||||
await loop.process_direct(
|
||||
"test", session_key="dream:consolidate-test", ephemeral=True,
|
||||
)
|
||||
mock_consolidate.assert_not_called()
|
||||
|
||||
async def test_ephemeral_response_reports_stop_reason(self, tmp_path, _make_loop):
|
||||
loop, store = _make_loop
|
||||
loop.provider.chat_with_retry.return_value = LLMResponse(
|
||||
@@ -606,7 +577,7 @@ class TestEphemeralDirect:
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
context_window_tokens=32_000,
|
||||
)
|
||||
|
||||
await loop.process_direct(
|
||||
@@ -625,6 +596,63 @@ class TestEphemeralDirect:
|
||||
assert "entry-21" not in request_text
|
||||
assert "entry-60" not in request_text
|
||||
|
||||
async def test_dream_turn_injects_memory_files_once_and_persists_session(self, tmp_path):
|
||||
"""Dream gets durable files from system context without losing its session record."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
markers = {
|
||||
"SOUL.md": "DREAM_SOUL_MARKER",
|
||||
"USER.md": "DREAM_USER_MARKER",
|
||||
"memory/MEMORY.md": "DREAM_MEMORY_MARKER",
|
||||
}
|
||||
store = MemoryStore(tmp_path)
|
||||
store.write_soul(markers["SOUL.md"])
|
||||
store.write_user(markers["USER.md"])
|
||||
store.write_memory(markers["memory/MEMORY.md"])
|
||||
store.append_history("history-marker")
|
||||
(tmp_path / "AGENTS.md").write_text("DREAM_AGENTS_MARKER", encoding="utf-8")
|
||||
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
|
||||
captured: dict[str, list[dict]] = {}
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.supports_tools = True
|
||||
provider.generation = MagicMock(max_tokens=4096)
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured["messages"] = kwargs["messages"]
|
||||
return LLMResponse(content="done", finish_reason="stop")
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=32_000,
|
||||
)
|
||||
session_key = "dream:single-memory-copy"
|
||||
|
||||
await loop.process_direct(
|
||||
prompt,
|
||||
session_key=session_key,
|
||||
ephemeral=True,
|
||||
tools=store.build_dream_tools(),
|
||||
)
|
||||
|
||||
messages = captured["messages"]
|
||||
system_prompt = str(messages[0]["content"])
|
||||
request_text = "\n".join(str(message.get("content", "")) for message in messages)
|
||||
for marker in [*markers.values(), "DREAM_AGENTS_MARKER"]:
|
||||
assert marker in system_prompt
|
||||
assert request_text.count(marker) == 1
|
||||
assert loop.sessions._get_session_path(session_key).exists()
|
||||
|
||||
|
||||
class TestEphemeralHooks:
|
||||
"""When ephemeral=True, extra hooks must not fire."""
|
||||
@@ -658,15 +686,14 @@ class TestEphemeralHooks:
|
||||
with (
|
||||
patch("nanobot.agent.loop.SessionManager"),
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub,
|
||||
patch("nanobot.agent.loop.Consolidator") as mock_consolidator_cls,
|
||||
patch("nanobot.agent.loop.Consolidator"),
|
||||
):
|
||||
mock_sub.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
mock_consolidator_cls.return_value.maybe_consolidate_by_tokens = AsyncMock()
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
context_window_tokens=32_000,
|
||||
hooks=[spy],
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.session.summary import SUMMARY_CONTINUATION_TEXT
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, context_window_tokens: int = 200_000) -> AgentLoop:
|
||||
@@ -66,13 +67,12 @@ def test_explicit_message_limit_still_starts_at_user_turn() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_replays_with_token_budget_only(tmp_path: Path) -> None:
|
||||
async def test_process_message_hands_complete_replay_to_runner(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, context_window_tokens=32_768)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage=None)
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
with patch.object(session, "get_history", wraps=session.get_history) as get_history:
|
||||
@@ -81,20 +81,16 @@ async def test_process_message_replays_with_token_budget_only(tmp_path: Path) ->
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert get_history.call_args.kwargs == {
|
||||
"max_tokens": loop._replay_token_budget(loop.llm_runtime()),
|
||||
"extend_to_user": False,
|
||||
}
|
||||
assert get_history.call_args.kwargs == {"extend_to_user": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_budget_keeps_current_user_as_replay_boundary(tmp_path: Path) -> None:
|
||||
async def test_runner_checkpoint_keeps_current_user_as_replay_boundary(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, context_window_tokens=8_000)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage=None)
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.add_message("user", "old")
|
||||
@@ -117,4 +113,6 @@ async def test_token_budget_keeps_current_user_as_replay_boundary(tmp_path: Path
|
||||
sent_messages = loop.provider.chat_with_retry.await_args.kwargs["messages"]
|
||||
sent_text = "\n".join(str(message.get("content")) for message in sent_messages)
|
||||
assert "new question" in sent_text
|
||||
assert "long older turn" not in sent_text
|
||||
assert [message["role"] for message in sent_messages] == ["system", "user", "user"]
|
||||
assert sent_messages[1]["content"] == SUMMARY_CONTINUATION_TEXT
|
||||
assert any(message.get("content") == "long older turn" for message in session.messages)
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.hook import (
|
||||
AgentHook,
|
||||
AgentHookContext,
|
||||
@@ -459,7 +460,7 @@ async def test_agent_loop_extra_hook_receives_calls(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
@@ -504,7 +505,7 @@ async def test_agent_loop_turn_hook_factories_receive_context(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
runtime=runtime,
|
||||
on_progress=on_progress,
|
||||
request_context=RequestContext(
|
||||
@@ -551,7 +552,7 @@ async def test_agent_loop_extra_hook_error_isolation(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hi"}], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
@@ -577,7 +578,9 @@ async def test_agent_loop_extra_hooks_do_not_swallow_loop_hook_errors(tmp_path):
|
||||
|
||||
with pytest.raises(RuntimeError, match="progress failed"):
|
||||
await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=bad_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=bad_progress,
|
||||
)
|
||||
|
||||
|
||||
@@ -596,7 +599,8 @@ async def test_agent_loop_no_hooks_backward_compat(tmp_path):
|
||||
loop.max_iterations = 2
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime()
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
assert result.final_content == (
|
||||
"I reached the maximum number of tool call iterations (2) "
|
||||
|
||||
@@ -4,14 +4,24 @@ import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.providers.base import (
|
||||
GenerationSettings,
|
||||
LLMResponse,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.session.summary import SUMMARY_CONTINUATION_TEXT
|
||||
|
||||
|
||||
def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop:
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
def _make_loop(
|
||||
tmp_path,
|
||||
*,
|
||||
estimated_tokens: int,
|
||||
context_window_tokens: int,
|
||||
max_tokens: int = 0,
|
||||
) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings(max_tokens=0)
|
||||
provider.generation = GenerationSettings(max_tokens=max_tokens)
|
||||
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
|
||||
_response = LLMResponse(content="ok", tool_calls=[])
|
||||
provider.chat_with_retry = AsyncMock(return_value=_response)
|
||||
@@ -23,6 +33,9 @@ def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
context_window_tokens=context_window_tokens,
|
||||
# These tests isolate Memory consolidation; Runner request fitting is
|
||||
# covered separately with realistic context windows.
|
||||
context_block_limit=10_000,
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator._SAFETY_BUFFER = 0
|
||||
@@ -30,158 +43,108 @@ def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
|
||||
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
async def test_runner_pressure_commits_summary_and_current_delta(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=2_000)
|
||||
loop.context_block_limit = 500
|
||||
loop.provider.generation = GenerationSettings(max_tokens=100)
|
||||
loop.provider.can_resume_conversation_state.return_value = False
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
|
||||
loop.consolidator.archive_session.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_above_threshold_triggers_consolidation(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
|
||||
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
{"role": role, "content": f"{role[0]}{turn}"}
|
||||
for turn in range(10)
|
||||
{"role": role, "content": f"old-{role}-{turn}"}
|
||||
for turn in range(6)
|
||||
for role in ("user", "assistant")
|
||||
]
|
||||
loop.sessions.save(session)
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
def estimate(messages, _tools, _model):
|
||||
contents = [str(message.get("content")) for message in messages]
|
||||
if contents and "SNIP" in contents[-1]:
|
||||
return 300, "test-counter"
|
||||
if any(content.startswith("old-") for content in contents):
|
||||
return 600, "test-counter"
|
||||
return 100, "test-counter"
|
||||
|
||||
assert loop.consolidator.archive_session.await_count >= 1
|
||||
loop.provider.estimate_prompt_tokens.side_effect = estimate
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="Current checkpoint.", tool_calls=[]),
|
||||
LLMResponse(content="done", tool_calls=[]),
|
||||
])
|
||||
|
||||
result = await loop.process_direct("continue the task", session_key="cli:test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_above_threshold_uses_fixed_recent_tail(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
|
||||
loop.consolidator.archive_session = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
{"role": role, "content": f"{role[0]}{turn}"}
|
||||
for turn in range(10)
|
||||
for role in ("user", "assistant")
|
||||
]
|
||||
loop.sessions.save(session)
|
||||
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
archive_end = loop.consolidator.archive_session.await_args.kwargs["archive_end"]
|
||||
archived_chunk = session.messages[:archive_end]
|
||||
assert [message["content"] for message in archived_chunk] == [
|
||||
"u0", "a0", "u1", "a1", "u2", "a2", "u3", "a3", "u4", "a4", "u5", "a5",
|
||||
]
|
||||
assert session.last_archived == 12
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
|
||||
loop.consolidator.archive_session = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
{"role": role, "content": f"{role[0]}{turn}"}
|
||||
for turn in range(5)
|
||||
for role in ("user", "assistant")
|
||||
]
|
||||
loop.sessions.save(session)
|
||||
|
||||
def mock_estimate(_session, *, runtime):
|
||||
return (500, "test")
|
||||
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
assert result.content == "done"
|
||||
assert loop.provider.chat_with_retry.await_count == 2
|
||||
model_request = loop.provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
||||
assert "Current checkpoint." in model_request[0]["content"]
|
||||
assert model_request[1]["content"] == SUMMARY_CONTINUATION_TEXT
|
||||
assert model_request[2]["content"] == "continue the task"
|
||||
|
||||
reloaded = loop.sessions.get_or_create("cli:test")
|
||||
meta = reloaded.metadata.get("_last_summary")
|
||||
assert meta is not None
|
||||
assert meta["text"] == "User discussed project status."
|
||||
|
||||
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert pending is not None
|
||||
assert pending["text"] == "User discussed project status."
|
||||
# _last_summary persists for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
assert reloaded.messages[0]["content"] == "old-user-0"
|
||||
assert reloaded.metadata["_last_summary"]["text"] == "Current checkpoint."
|
||||
assert reloaded.messages[reloaded.last_archived]["content"] == (
|
||||
SUMMARY_CONTINUATION_TEXT
|
||||
)
|
||||
assert [message["content"] for message in reloaded.get_history()] == [
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
"continue the task",
|
||||
"done",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
loop.auto_compact.prepare_session = MagicMock(
|
||||
return_value=(
|
||||
session,
|
||||
{"text": "earlier context", "last_active": session.updated_at.isoformat()},
|
||||
)
|
||||
) # type: ignore[method-assign]
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop.process_direct("hello", session_key="cli:test", runtime=runtime)
|
||||
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_any_await(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert len(loop.consolidator.maybe_consolidate_by_tokens.call_args_list) == 2
|
||||
assert all(
|
||||
call.kwargs["runtime"] is runtime
|
||||
for call in loop.consolidator.maybe_consolidate_by_tokens.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preflight_consolidation_before_llm_call(tmp_path) -> None:
|
||||
"""Verify preflight consolidation runs before the LLM call in process_direct."""
|
||||
order: list[str] = []
|
||||
|
||||
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
|
||||
|
||||
archived_session_keys: list[str | None] = []
|
||||
|
||||
async def track_consolidate(session, *, archive_end, runtime):
|
||||
order.append("consolidate")
|
||||
archived_session_keys.append(session.key)
|
||||
return True
|
||||
loop.consolidator.archive_session = track_consolidate # type: ignore[method-assign]
|
||||
|
||||
async def track_llm(*args, **kwargs):
|
||||
order.append("llm")
|
||||
return LLMResponse(content="ok", tool_calls=[])
|
||||
loop.provider.chat_with_retry = track_llm
|
||||
loop.provider.chat_stream_with_retry = track_llm
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
async def test_native_provider_compaction_commits_portable_terminal_checkpoint(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=2_000)
|
||||
session = loop.sessions.get_or_create("cli:native")
|
||||
session.messages = [
|
||||
{"role": role, "content": f"{role[0]}{turn}"}
|
||||
for turn in range(10)
|
||||
for role in ("user", "assistant")
|
||||
{"role": "user", "content": "accepted history"},
|
||||
{"role": "assistant", "content": "accepted answer"},
|
||||
]
|
||||
loop.sessions.save(session)
|
||||
call_count = [0]
|
||||
def mock_estimate(_session, *, runtime):
|
||||
call_count[0] += 1
|
||||
return (1000 if call_count[0] <= 1 else 80, "test")
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
compacted_state = ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
provider="openai:test",
|
||||
model="test-model",
|
||||
version=1,
|
||||
payload={"items": [{"type": "compaction", "encrypted_content": "opaque"}]},
|
||||
)
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="done",
|
||||
provider_state=compacted_state,
|
||||
provider_compaction_applied=True,
|
||||
provider_compaction_state=compacted_state,
|
||||
provider_compaction_scope="current_request",
|
||||
))
|
||||
loop.consolidator.summarize_provider_compaction = AsyncMock(
|
||||
return_value="portable terminal checkpoint",
|
||||
)
|
||||
|
||||
await loop.process_direct("hello", session_key="cli:test")
|
||||
result = await loop.process_direct("continue", session_key="cli:native")
|
||||
|
||||
assert "consolidate" in order
|
||||
assert "llm" in order
|
||||
assert order.index("consolidate") < order.index("llm")
|
||||
assert archived_session_keys == ["cli:test"]
|
||||
assert result.content == "done"
|
||||
summarize = loop.consolidator.summarize_provider_compaction
|
||||
summarize.assert_awaited_once()
|
||||
assert summarize.await_args.args[0] == compacted_state
|
||||
accepted = summarize.await_args.args[1]
|
||||
accepted_contents = [message.get("content") for message in accepted]
|
||||
assert "accepted history" in accepted_contents
|
||||
assert "accepted answer" in accepted_contents
|
||||
assert "continue" in accepted_contents
|
||||
assert "done" not in accepted_contents
|
||||
reloaded = loop.sessions.get_or_create("cli:native")
|
||||
assert reloaded.provider_state is None
|
||||
assert reloaded.metadata["_last_summary"]["text"] == (
|
||||
"portable terminal checkpoint"
|
||||
)
|
||||
assert reloaded.messages[reloaded.last_archived]["content"] == (
|
||||
SUMMARY_CONTINUATION_TEXT
|
||||
)
|
||||
assert [message["content"] for message in reloaded.get_history()] == [
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
"done",
|
||||
]
|
||||
|
||||
@@ -69,7 +69,6 @@ async def test_outbound_no_longer_carries_generated_media(
|
||||
),
|
||||
image_generation_provider_config=ProviderConfig(api_key="sk-or-test"),
|
||||
)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
result = await loop._process_message(
|
||||
InboundMessage(
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
@@ -84,7 +85,9 @@ class TestToolEventProgress:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -155,7 +158,9 @@ class TestToolEventProgress:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -225,7 +230,9 @@ class TestToolEventProgress:
|
||||
)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
@@ -263,7 +270,9 @@ class TestToolEventProgress:
|
||||
file_events.extend(file_edit_events)
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
assert file_events == []
|
||||
@@ -416,7 +425,6 @@ class TestToolEventProgress:
|
||||
None,
|
||||
),
|
||||
)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -464,7 +472,6 @@ class TestToolEventProgress:
|
||||
provider.chat_stream_with_retry = AsyncMock()
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="whatsapp",
|
||||
@@ -503,7 +510,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -557,7 +563,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -602,7 +607,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -646,7 +650,6 @@ class TestToolEventProgress:
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.max_iterations = 1
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -738,7 +741,6 @@ class TestToolEventProgress:
|
||||
)
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -806,9 +808,6 @@ class TestToolEventProgress:
|
||||
return "ok"
|
||||
|
||||
loop.tools.execute = execute_tool
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=False
|
||||
)
|
||||
|
||||
session_key = "websocket:chat-a"
|
||||
session = loop.sessions.get_or_create(session_key)
|
||||
@@ -940,7 +939,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -1019,7 +1017,7 @@ class TestToolEventProgress:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
on_stream=on_stream,
|
||||
@@ -1039,7 +1037,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -1123,7 +1120,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await asyncio.wait_for(loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
@@ -1172,7 +1168,6 @@ class TestToolEventProgress:
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
_attach_webui_runtime_events(loop, bus)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@@ -1259,7 +1254,6 @@ class TestToolEventProgress:
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="slack",
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||
@@ -55,7 +56,7 @@ async def test_ephemeral_runner_enters_and_restores_turn_scopes(tmp_path):
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
ephemeral=True,
|
||||
turn_scopes=[goal_mutation_permission(True)],
|
||||
@@ -111,7 +112,6 @@ async def test_goal_command_can_implement_plan_from_prior_discussion(tmp_path):
|
||||
LLMResponse(content="done", tool_calls=[], usage=None),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.add_message("user", "Let's agree on the migration implementation.")
|
||||
session.add_message("assistant", "Use the staged migration plan and run integration tests.")
|
||||
@@ -165,7 +165,6 @@ async def test_runtime_context_is_persisted_as_next_turn_prompt_prefix(tmp_path)
|
||||
LLMResponse(content="second answer", usage=None),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
provider_calls: list[str | None] = []
|
||||
|
||||
@@ -219,7 +218,6 @@ async def test_webui_quote_reaches_model_without_leaking_into_public_history(tmp
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="answer", usage=None))
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("websocket:chat")
|
||||
quote = webui_quote_runtime_context({
|
||||
WEBUI_QUOTE_METADATA: "the selected answer excerpt",
|
||||
@@ -264,7 +262,6 @@ async def test_runtime_context_provider_runs_once_across_tool_iterations(tmp_pat
|
||||
LLMResponse(content="done", usage=None),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
provider_calls = 0
|
||||
|
||||
async def provide_context(_request):
|
||||
@@ -309,7 +306,6 @@ async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
|
||||
LLMResponse(content="handled as a one-time task", tool_calls=[], usage=None),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("api:default")
|
||||
session.add_message("user", "/goal old completed request")
|
||||
session.add_message("assistant", "The old request is complete.")
|
||||
@@ -340,7 +336,8 @@ async def test_loop_max_iterations_message_stays_stable(tmp_path):
|
||||
loop.max_iterations = 2
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime()
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
assert result.final_content == (
|
||||
@@ -362,7 +359,7 @@ async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="cli",
|
||||
@@ -401,7 +398,7 @@ async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp
|
||||
endings.append(resuming)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
@@ -428,7 +425,9 @@ async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
|
||||
deltas.append(delta)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_stream=on_stream
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
)
|
||||
|
||||
assert result.final_content == "Hello World"
|
||||
@@ -451,7 +450,9 @@ async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
|
||||
deltas.append(delta)
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_stream=on_stream
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_stream=on_stream,
|
||||
)
|
||||
|
||||
assert result.final_content == "Hello World"
|
||||
@@ -472,7 +473,8 @@ async def test_loop_retries_think_only_final_response(tmp_path):
|
||||
loop.provider.chat_with_retry = chat_with_retry
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime()
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
assert result.final_content == "Recovered answer"
|
||||
@@ -582,7 +584,6 @@ async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
||||
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
first = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="first question")
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.context import ContextBuilder, TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
@@ -45,6 +45,10 @@ from nanobot.session.recovery import (
|
||||
RUNTIME_CHECKPOINT_KEY,
|
||||
restore_runtime_checkpoint,
|
||||
)
|
||||
from nanobot.session.summary import (
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
SessionSummaryCheckpoint,
|
||||
)
|
||||
from nanobot.session.turn_continuation import (
|
||||
INTERNAL_CONTINUATION_META,
|
||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||
@@ -79,6 +83,13 @@ def _agent_run_result(
|
||||
)
|
||||
|
||||
|
||||
def _assembled_messages(
|
||||
builder: ContextBuilder,
|
||||
transcript_input: TranscriptInput,
|
||||
) -> list[dict]:
|
||||
return builder.build_transcript(transcript_input, include_memory=False)
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
loop = AgentLoop.__new__(AgentLoop)
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
@@ -499,6 +510,60 @@ def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
||||
assert public_history_message(session.messages[0])["content"] == []
|
||||
|
||||
|
||||
def test_save_turn_commits_summary_boundary_without_rewriting_raw_history() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:summary-checkpoint")
|
||||
session.add_message("user", "inspect the project")
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "inspect the project"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "inspect", "arguments": "{}"},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-1",
|
||||
"name": "inspect",
|
||||
"content": "full current result",
|
||||
},
|
||||
{"role": "assistant", "content": "done"},
|
||||
]
|
||||
|
||||
loop._save_turn(
|
||||
session,
|
||||
messages,
|
||||
skip=2,
|
||||
summary_checkpoint=SessionSummaryCheckpoint(
|
||||
summary="Current working-memory checkpoint.",
|
||||
transcript_boundary=2,
|
||||
),
|
||||
input_persisted_early=True,
|
||||
)
|
||||
|
||||
assert [message["role"] for message in session.messages] == [
|
||||
"user", "user", "assistant", "tool", "assistant",
|
||||
]
|
||||
assert session.messages[0]["content"] == "inspect the project"
|
||||
assert session.messages[1]["content"] == SUMMARY_CONTINUATION_TEXT
|
||||
assert session.messages[1]["_hidden_history"] is True
|
||||
assert session.last_archived == 1
|
||||
assert session.metadata["_last_summary"]["text"] == (
|
||||
"Current working-memory checkpoint."
|
||||
)
|
||||
assert [message["content"] for message in session.get_history()] == [
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
"",
|
||||
"full current result",
|
||||
"done",
|
||||
]
|
||||
|
||||
|
||||
def test_save_turn_acknowledges_every_merged_recovery_followup() -> None:
|
||||
"""Persisting a merged injected row retires every durable follow-up ID."""
|
||||
loop = _mk_loop()
|
||||
@@ -930,10 +995,13 @@ async def test_runtime_checkpoint_keeps_provider_state_out_of_public_metadata(
|
||||
session = loop.sessions.get_or_create("cli:private-checkpoint")
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[
|
||||
TranscriptInput(
|
||||
history=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "question"},
|
||||
],
|
||||
current_message=None,
|
||||
),
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
)
|
||||
@@ -956,7 +1024,6 @@ async def test_runtime_checkpoint_keeps_provider_state_out_of_public_metadata(
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="persist me")
|
||||
@@ -976,7 +1043,6 @@ async def test_subagent_followup_stages_provider_state_before_turn_runs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
session = loop.sessions.get_or_create("cli:subagent-crash")
|
||||
@@ -1006,9 +1072,8 @@ async def test_subagent_followup_state_is_durable_before_prompt_assembly(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
||||
loop.context.build_system_prompt = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("prompt boom"),
|
||||
)
|
||||
session = loop.sessions.get_or_create("cli:subagent-prompt-crash")
|
||||
@@ -1039,10 +1104,9 @@ async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.return_value = True
|
||||
build_initial_messages = loop._build_initial_messages
|
||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
||||
build_system_prompt = loop.context.build_system_prompt
|
||||
loop.context.build_system_prompt = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("prompt boom"),
|
||||
)
|
||||
session = loop.sessions.get_or_create("cli:subagent-redelivery")
|
||||
@@ -1066,7 +1130,7 @@ async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
|
||||
message.get("content")
|
||||
for message in persisted.provider_state.pending_messages
|
||||
].count("subagent result") == 1
|
||||
loop._build_initial_messages = build_initial_messages # type: ignore[method-assign]
|
||||
loop.context.build_system_prompt = build_system_prompt # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("provider boom"),
|
||||
)
|
||||
@@ -1091,7 +1155,6 @@ async def test_subagent_followup_clears_state_before_compatibility_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.can_resume_conversation_state.side_effect = RuntimeError(
|
||||
"compatibility boom"
|
||||
)
|
||||
@@ -1119,7 +1182,6 @@ async def test_subagent_followup_clears_state_before_compatibility_failure(
|
||||
async def test_process_message_persists_unified_session_delivery_route(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop._unified_session = True
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
@@ -1220,7 +1282,6 @@ async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path)
|
||||
img_b.write_bytes(_PNG_1X1)
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("interrupt")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
@@ -1252,7 +1313,6 @@ async def test_process_message_persists_media_only_turn_without_text(tmp_path: P
|
||||
img.write_bytes(_PNG_1X1)
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
@@ -1276,7 +1336,6 @@ async def test_process_message_persists_media_only_turn_without_text(tmp_path: P
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop._run_agent_loop = AsyncMock(return_value=_agent_run_result(
|
||||
"done",
|
||||
[
|
||||
@@ -1309,7 +1368,6 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
session = loop.sessions.get_or_create("feishu:c-auto")
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
@@ -1319,7 +1377,8 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, *, metadata=None, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, *, metadata=None, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
calls.append({"initial_messages": initial_messages, "metadata": metadata})
|
||||
if len(calls) == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1377,7 +1436,6 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
session = loop.sessions.get_or_create("feishu:c-stream")
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
@@ -1387,8 +1445,9 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
|
||||
|
||||
calls = 0
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, *, on_stream=None, on_stream_end=None, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, *, on_stream=None, on_stream_end=None, **_kwargs):
|
||||
nonlocal calls
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1450,7 +1509,6 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
session = loop.sessions.get_or_create("websocket:c-auto")
|
||||
session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
@@ -1460,8 +1518,9 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
||||
|
||||
calls = 0
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
nonlocal calls
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return _agent_run_result(
|
||||
@@ -1513,7 +1572,6 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
||||
return_value=[
|
||||
{"role": "system", "content": "system"},
|
||||
@@ -1552,7 +1610,6 @@ async def test_process_message_uses_explicit_session_for_goal_context(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
chat_session = loop.sessions.get_or_create("websocket:chat-with-goal")
|
||||
chat_session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
@@ -1623,7 +1680,7 @@ async def test_run_agent_loop_continuation_reads_latest_goal_metadata(
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -1700,7 +1757,6 @@ async def test_request_context_uses_effective_key_for_spawn_tool(tmp_path: Path)
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=MagicMock()) # unused because _run_agent_loop is stubbed
|
||||
|
||||
session = loop.sessions.get_or_create("feishu:c3")
|
||||
@@ -1749,11 +1805,10 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
from nanobot.command.router import CommandContext
|
||||
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
checkpoint_saved = asyncio.Event()
|
||||
|
||||
async def interrupted_run_agent_loop(_initial_messages, *, session=None, **_kwargs):
|
||||
async def interrupted_run_agent_loop(_transcript_input, *, session=None, **_kwargs):
|
||||
assert session is not None
|
||||
loop._set_runtime_checkpoint(
|
||||
session,
|
||||
@@ -1813,7 +1868,8 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
assert interrupted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True
|
||||
assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None
|
||||
|
||||
async def resumed_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def resumed_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
return _agent_run_result(
|
||||
"next answer",
|
||||
[*initial_messages, {"role": "assistant", "content": "next answer"}],
|
||||
@@ -1852,7 +1908,6 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.add_message("user", "question")
|
||||
@@ -1864,7 +1919,8 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
record_runtime = MagicMock(wraps=loop.runtime_event_publisher.record_turn_runtime)
|
||||
loop.runtime_event_publisher.record_turn_runtime = record_runtime
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
seen["initial_messages"] = initial_messages
|
||||
seen["runtime"] = kwargs["runtime"]
|
||||
seen["request_context"] = kwargs["request_context"]
|
||||
@@ -1898,11 +1954,6 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
assert request.metadata == {"subagent_task_id": "sub-1"}
|
||||
assert request.turn_id
|
||||
record_runtime.assert_called_once_with("cli:test", runtime)
|
||||
assert len(loop.consolidator.maybe_consolidate_by_tokens.call_args_list) == 2
|
||||
assert all(
|
||||
call.kwargs["runtime"] is runtime
|
||||
for call in loop.consolidator.maybe_consolidate_by_tokens.call_args_list
|
||||
)
|
||||
initial_messages = seen["initial_messages"]
|
||||
assert isinstance(initial_messages, list)
|
||||
non_system = [m for m in initial_messages if m.get("role") != "system"]
|
||||
@@ -1937,10 +1988,10 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
turn_usage = LLMUsage.reported(input_tokens=64, output_tokens=9)
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -1962,11 +2013,9 @@ async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) ->
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=False
|
||||
)
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -2000,9 +2049,6 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=False
|
||||
)
|
||||
visited: list[str] = []
|
||||
|
||||
for name in (
|
||||
@@ -2022,7 +2068,8 @@ async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Pat
|
||||
|
||||
setattr(loop, name, record)
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
@@ -2063,9 +2110,9 @@ async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Pat
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
return _agent_run_result(
|
||||
"ack",
|
||||
[*initial_messages, {"role": "assistant", "content": "ack"}],
|
||||
@@ -2188,7 +2235,6 @@ async def test_request_context_passes_thread_session_key_to_spawn(tmp_path: Path
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
thread_session = loop.sessions.get_or_create("slack:C123:1700.42")
|
||||
thread_session.add_message("user", "thread question")
|
||||
@@ -2196,7 +2242,8 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||
async def fake_run_agent_loop(transcript_input, **kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
seen["initial_messages"] = initial_messages
|
||||
seen["request_context"] = kwargs["request_context"]
|
||||
return _agent_run_result(
|
||||
@@ -2246,14 +2293,16 @@ async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(t
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_after_unanswered_user_keeps_tool_call_pairing(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("feishu:c-merge")
|
||||
session.add_message("user", "earlier question that never got an answer")
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
assert [m["role"] for m in initial_messages] == ["system", "user"]
|
||||
async def fake_run_agent_loop(transcript_input, **_kwargs):
|
||||
initial_messages = _assembled_messages(loop.context, transcript_input)
|
||||
assert [m["role"] for m in initial_messages] == ["system", "user", "user"]
|
||||
assert initial_messages[-2]["content"] == "earlier question that never got an answer"
|
||||
assert initial_messages[-1]["content"] == "and another thing"
|
||||
return _agent_run_result(
|
||||
"done",
|
||||
[
|
||||
|
||||
@@ -46,7 +46,6 @@ def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
|
||||
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, ["first answer", "second answer"])
|
||||
loop.context.memory.write_memory("private durable memory")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
key = "websocket:transient-test"
|
||||
loop.sessions.get_or_create_transient(
|
||||
key,
|
||||
@@ -71,7 +70,6 @@ async def test_transient_session_keeps_history_without_persisting_or_durable_too
|
||||
"assistant",
|
||||
]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import (
|
||||
RequestContext,
|
||||
@@ -133,7 +134,7 @@ async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) ->
|
||||
metadata = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
@@ -234,7 +235,7 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception(
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="runner failed"):
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(
|
||||
channel="slack",
|
||||
|
||||
@@ -113,54 +113,6 @@ class TestHistoryWithCursor:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
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):
|
||||
"""Regression: entries missing the cursor key should be silently skipped."""
|
||||
store.history_file.write_text(
|
||||
|
||||
@@ -8,6 +8,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
_ARCHIVE_PROMPT = render_template("agent/consolidator_archive.md", strip=True)
|
||||
|
||||
|
||||
class TestNewCommandArchival:
|
||||
"""Test /new archival behavior with the structured archive flow."""
|
||||
@@ -117,7 +121,7 @@ class TestNewCommandArchival:
|
||||
await loop.aclose()
|
||||
sent = loop.provider.chat_with_retry.call_args.kwargs["messages"]
|
||||
assert sent[1:-1] == ordinary_history
|
||||
assert "final 2 conversation messages" in sent[-1]["content"]
|
||||
assert sent[-1]["content"] == _ARCHIVE_PROMPT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None:
|
||||
|
||||
@@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.context_governance import ContextWindowExceededError
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
@@ -34,6 +36,38 @@ def _make_usage_spec(provider, tools):
|
||||
)
|
||||
|
||||
|
||||
def test_initial_transcript_is_built_from_structured_turn_input() -> None:
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
transcript_input = TranscriptInput(
|
||||
history=[{"role": "user", "content": "earlier"}],
|
||||
current_message="fresh",
|
||||
)
|
||||
expected = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "earlier"},
|
||||
{"role": "user", "content": "fresh"},
|
||||
]
|
||||
transcript_builder = MagicMock(return_value=expected)
|
||||
spec = make_run_spec(
|
||||
provider,
|
||||
initial_messages=None,
|
||||
transcript_input=transcript_input,
|
||||
transcript_builder=transcript_builder,
|
||||
tools=MagicMock(),
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
|
||||
messages, compaction = AgentRunner._initial_transcript_and_compaction(spec)
|
||||
|
||||
assert messages == expected
|
||||
assert compaction is None
|
||||
transcript_builder.assert_called_once_with(transcript_input)
|
||||
|
||||
|
||||
def test_usage_or_estimate_replaces_reported_zero_for_content(monkeypatch) -> None:
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
@@ -56,6 +90,7 @@ def test_usage_or_estimate_replaces_reported_zero_for_content(monkeypatch) -> No
|
||||
_make_usage_spec(provider, tools),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
response,
|
||||
tool_definitions=tools.get_definitions(),
|
||||
)
|
||||
|
||||
assert usage == LLMUsage.estimated(input_tokens=12, output_tokens=7).with_timing(
|
||||
@@ -100,6 +135,7 @@ def test_usage_or_estimate_counts_tool_call_output_for_reported_zero(monkeypatch
|
||||
_make_usage_spec(provider, tools),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
response,
|
||||
tool_definitions=tools.get_definitions(),
|
||||
)
|
||||
|
||||
assert usage == LLMUsage.estimated(input_tokens=13, output_tokens=9)
|
||||
@@ -132,6 +168,7 @@ def test_usage_or_estimate_counts_error_without_estimating_tokens(
|
||||
_make_usage_spec(provider, tools),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
response,
|
||||
tool_definitions=tools.get_definitions(),
|
||||
)
|
||||
|
||||
assert usage is not None
|
||||
@@ -167,6 +204,7 @@ def test_usage_or_estimate_trusts_positive_reported_total(monkeypatch) -> None:
|
||||
_make_usage_spec(provider, tools),
|
||||
[{"role": "user", "content": "hello"}],
|
||||
response,
|
||||
tool_definitions=tools.get_definitions(),
|
||||
)
|
||||
|
||||
assert usage is not None
|
||||
@@ -336,14 +374,12 @@ async def test_runner_replays_provider_state_without_chat_projection_duplicates(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
||||
async def test_runner_preserves_tool_result_before_rejecting_unfit_followup():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.can_resume_conversation_state.return_value = True
|
||||
provider.supports_native_compaction.return_value = False
|
||||
calls = 0
|
||||
captured_context: ProviderCallContext | None = None
|
||||
checkpoints: list[dict] = []
|
||||
state = ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
@@ -354,7 +390,7 @@ async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
||||
)
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
nonlocal calls, captured_context
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return LLMResponse(
|
||||
@@ -368,7 +404,6 @@ async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
||||
],
|
||||
provider_state=state,
|
||||
)
|
||||
captured_context = kwargs["provider_context"]
|
||||
return LLMResponse(content="done")
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
@@ -379,6 +414,7 @@ async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
||||
async def checkpoint(payload: dict) -> None:
|
||||
checkpoints.append(payload)
|
||||
|
||||
with pytest.raises(ContextWindowExceededError):
|
||||
await AgentRunner().run(make_run_spec(
|
||||
provider,
|
||||
initial_messages=[
|
||||
@@ -395,21 +431,19 @@ async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
||||
checkpoint_callback=checkpoint,
|
||||
))
|
||||
|
||||
assert captured_context is not None
|
||||
assert captured_context.conversation_state is not None
|
||||
pending = captured_context.conversation_state.pending_messages
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["role"] == "tool"
|
||||
assert "compacted to fit context" in pending[0]["content"]
|
||||
assert pending[0]["content"] != "x" * 5_000
|
||||
assert calls == 1
|
||||
completed_checkpoint = next(
|
||||
checkpoint
|
||||
for checkpoint in checkpoints
|
||||
if checkpoint["phase"] == "tools_completed"
|
||||
)
|
||||
checkpoint_pending = completed_checkpoint["provider_state"].pending_messages
|
||||
assert "compacted to fit context" in checkpoint_pending[0]["content"]
|
||||
assert checkpoint_pending[0]["content"] != "x" * 5_000
|
||||
assert checkpoint_pending == [{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"name": "read_file",
|
||||
"content": "x" * 5_000,
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -52,7 +52,31 @@ async def test_runner_returns_tool_exception_to_model_for_recovery():
|
||||
{"name": "list_dir", "status": "error", "detail": "boom"}
|
||||
]
|
||||
tool_message = next(message for message in result.messages if message.get("role") == "tool")
|
||||
retry_hint = "[Analyze the error above and try a different approach.]"
|
||||
assert "Error: RuntimeError: boom" in tool_message["content"]
|
||||
assert tool_message["content"].count(retry_hint) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_does_not_duplicate_existing_retry_hint():
|
||||
retry_hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
tools = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=ToolResult.error("Error: boom" + retry_hint)),
|
||||
)
|
||||
|
||||
results, events = await execute_tool_calls(
|
||||
tools,
|
||||
[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
concurrent=False,
|
||||
external_lookup_counts={},
|
||||
workspace_violation_counts={},
|
||||
hook=AgentHook(),
|
||||
context=AgentHookContext(iteration=0, messages=[]),
|
||||
)
|
||||
|
||||
assert results == ["Error: boom" + retry_hint]
|
||||
assert results[0].count(retry_hint) == 1
|
||||
assert events[0]["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
+1009
-266
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
@@ -617,7 +618,7 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
@@ -711,7 +712,10 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "initial message from user A"}],
|
||||
TranscriptInput(
|
||||
history=[{"role": "user", "content": "initial message from user A"}],
|
||||
current_message=None,
|
||||
),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -746,25 +750,27 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
||||
),
|
||||
]
|
||||
|
||||
injected = [message for message in result.messages if message.get("role") == "user"][-1]
|
||||
assert "follow-up from the second speaker" in str(injected["content"])
|
||||
injected = [message for message in result.messages if message.get("role") == "user"][-2:]
|
||||
assert str(injected[0]["content"]).startswith("follow-up from the second speaker\n\n")
|
||||
assert str(injected[1]["content"]).startswith("another follow-up\n\n")
|
||||
model_messages = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
||||
assert "telegram | group-1 | user-b | message-2" in str(model_messages)
|
||||
assert "Bob | topic-7" in str(model_messages)
|
||||
assert "telegram | group-1 | user-c | message-3" in str(model_messages)
|
||||
assert "Carol | topic-7" in str(model_messages)
|
||||
assert injected["_meta"][RUNTIME_CONTEXT_MESSAGE_META]["sources"] == [
|
||||
"identity",
|
||||
"identity",
|
||||
]
|
||||
assert all(
|
||||
message["_meta"][RUNTIME_CONTEXT_MESSAGE_META]["sources"] == ["identity"]
|
||||
for message in injected
|
||||
)
|
||||
|
||||
loop._save_turn(session, result.messages, skip=1)
|
||||
persisted = [message for message in session.messages if message.get("role") == "user"][-1]
|
||||
assert "telegram | group-1 | user-b | message-2" in str(persisted["content"])
|
||||
assert "telegram | group-1 | user-c | message-3" in str(persisted["content"])
|
||||
assert public_history_message(persisted)["content"] == (
|
||||
"follow-up from the second speaker\n\nanother follow-up"
|
||||
)
|
||||
persisted = [message for message in session.messages if message.get("role") == "user"][-2:]
|
||||
assert "telegram | group-1 | user-b | message-2" in str(persisted[0]["content"])
|
||||
assert "telegram | group-1 | user-c | message-3" in str(persisted[1]["content"])
|
||||
assert [public_history_message(message)["content"] for message in persisted] == [
|
||||
"follow-up from the second speaker",
|
||||
"another follow-up",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -812,7 +818,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
@@ -831,8 +837,8 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
|
||||
"""Multiple injected follow-ups should not create lossy consecutive user messages."""
|
||||
async def test_model_request_merges_injected_user_messages_without_losing_media():
|
||||
"""The model copy may merge follow-ups while the raw transcript keeps each event."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -891,10 +897,17 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
|
||||
for block in injected["content"]
|
||||
if isinstance(block, dict)
|
||||
)
|
||||
assert [message["content"] for message in result.messages[-3:-1]] == [
|
||||
[
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||
{"type": "text", "text": "look at this"},
|
||||
],
|
||||
"and answer briefly",
|
||||
]
|
||||
|
||||
|
||||
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
|
||||
"""Merged follow-ups stay acknowledged together after a later save."""
|
||||
def test_runner_append_keeps_recovery_followups_separate() -> None:
|
||||
"""Each raw follow-up keeps its own recovery identity."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
||||
|
||||
@@ -904,10 +917,12 @@ def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
|
||||
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
|
||||
)
|
||||
|
||||
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
|
||||
assert [message["content"] for message in messages] == ["first", "second"]
|
||||
assert [message[PENDING_FOLLOWUP_ID_KEY] for message in messages] == ["one", "two"]
|
||||
|
||||
|
||||
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
||||
def test_model_request_merge_preserves_runtime_markers_with_media() -> None:
|
||||
from nanobot.agent.context_governance import ContextGovernor
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
@@ -944,8 +959,9 @@ def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
||||
},
|
||||
])
|
||||
|
||||
assert len(messages) == 1
|
||||
merged = messages[0]
|
||||
assert len(messages) == 2
|
||||
merged = ContextGovernor._merge_adjacent_user_messages_for_model(messages)[0]
|
||||
assert len(messages) == 2
|
||||
assert "private first" in str(merged["content"])
|
||||
assert "private second" in str(merged["content"])
|
||||
persisted = {
|
||||
@@ -1476,7 +1492,7 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
result = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "hello"}], current_message=None),
|
||||
runtime=runtime,
|
||||
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||
pending_queue=pending_queue,
|
||||
@@ -1677,15 +1693,17 @@ async def test_drain_injections_after_recoverable_tool_error():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_on_llm_error():
|
||||
"""Pending injections should be drained when the LLM returns an error finish_reason."""
|
||||
"""A follow-up after an error stays raw and reaches the next model request."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
call_count = {"n": 0}
|
||||
requests: list[list[dict]] = []
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
requests.append(messages)
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
@@ -1709,11 +1727,20 @@ async def test_drain_injections_on_llm_error():
|
||||
|
||||
runner = AgentRunner()
|
||||
result = await runner.run(make_run_spec(provider,
|
||||
initial_messages=[
|
||||
initial_messages=None,
|
||||
transcript_input=TranscriptInput(
|
||||
history=[
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "previous response"},
|
||||
{"role": "user", "content": "trigger error"},
|
||||
],
|
||||
current_message=None,
|
||||
),
|
||||
transcript_builder=lambda transcript: [
|
||||
{"role": "system", "content": "system"},
|
||||
*transcript.history,
|
||||
],
|
||||
consolidate_history=AsyncMock(return_value=None),
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=5,
|
||||
@@ -1723,11 +1750,15 @@ async def test_drain_injections_on_llm_error():
|
||||
|
||||
assert result.had_injections is True
|
||||
assert result.final_content == "recovered answer"
|
||||
injected = [
|
||||
m for m in result.messages
|
||||
if m.get("role") == "user" and "follow-up after LLM error" in str(m.get("content", ""))
|
||||
assert "follow-up after LLM error" in str(requests[1])
|
||||
assert [
|
||||
message["content"]
|
||||
for message in result.messages
|
||||
if message.get("role") == "user"
|
||||
][-2:] == [
|
||||
"trigger error",
|
||||
"follow-up after LLM error",
|
||||
]
|
||||
assert len(injected) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,6 +9,7 @@ channels, gated by ``context.streamed_reasoning`` rather than
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -82,6 +83,18 @@ class _LifecycleRecordingHook(AgentHook):
|
||||
self.events.append(f"hosted_tool:{event.get('phase')}")
|
||||
|
||||
|
||||
class _BlockingReasoningEndHook(_LifecycleRecordingHook):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.reasoning_end_started = asyncio.Event()
|
||||
self.release_reasoning_end = asyncio.Event()
|
||||
|
||||
async def emit_reasoning_end(self) -> None:
|
||||
self.reasoning_end_started.set()
|
||||
await self.release_reasoning_end.wait()
|
||||
await super().emit_reasoning_end()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_preserves_reasoning_fields_in_assistant_history():
|
||||
"""Reasoning fields ride along on the persisted assistant message so
|
||||
@@ -554,6 +567,86 @@ async def test_runner_closes_native_reasoning_before_hosted_tool_event():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_closes_native_reasoning_when_stream_is_cancelled():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
reasoning_started = asyncio.Event()
|
||||
release_provider = asyncio.Event()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("inspect")
|
||||
reasoning_started.set()
|
||||
await release_provider.wait()
|
||||
raise AssertionError("the cancelled provider call should not complete")
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
hook = _LifecycleRecordingHook()
|
||||
|
||||
task = asyncio.create_task(AgentRunner().run(make_run_spec(
|
||||
provider,
|
||||
initial_messages=[{"role": "user", "content": "inspect"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
)))
|
||||
await reasoning_started.wait()
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert hook.events == ["reasoning:inspect", "reasoning_end"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_settles_native_reasoning_end_before_propagating_cancellation():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_content_delta=None, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("inspect")
|
||||
if on_content_delta:
|
||||
await on_content_delta("done")
|
||||
raise AssertionError("the cancelled provider call should not complete")
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
hook = _BlockingReasoningEndHook()
|
||||
|
||||
task = asyncio.create_task(AgentRunner().run(make_run_spec(
|
||||
provider,
|
||||
initial_messages=[{"role": "user", "content": "inspect"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
)))
|
||||
await hook.reasoning_end_started.wait()
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
hook.release_reasoning_end.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert hook.events == ["reasoning:inspect", "reasoning_end"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from nanobot.providers.base import ProviderConversationState
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
RuntimeContextBlock,
|
||||
append_runtime_context,
|
||||
)
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.summary import SUMMARY_CONTINUATION_TEXT
|
||||
|
||||
|
||||
def _assert_no_orphans(history: list[dict]) -> None:
|
||||
@@ -136,58 +137,6 @@ def test_legitimate_tool_pairs_preserved_after_trim():
|
||||
assert history[0]["role"] == "user"
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_keeps_recent_messages():
|
||||
session = Session(key="test:trim")
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert len(session.messages) == 4
|
||||
assert session.messages[0]["content"] == "msg6"
|
||||
assert session.messages[-1]["content"] == "msg9"
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_adjusts_last_archived():
|
||||
session = Session(key="test:trim-cons")
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
session.last_archived = 7
|
||||
|
||||
session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert len(session.messages) == 4
|
||||
assert session.last_archived == 1
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_zero_clears_session():
|
||||
session = Session(key="test:trim-zero")
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
session.last_archived = 5
|
||||
|
||||
session.retain_recent_legal_suffix(0)
|
||||
|
||||
assert session.messages == []
|
||||
assert session.last_archived == 0
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_keeps_legal_tool_boundary():
|
||||
session = Session(key="test:trim-tools")
|
||||
session.messages.append({"role": "user", "content": "old"})
|
||||
session.messages.extend(_tool_turn("old", 0))
|
||||
session.messages.append({"role": "user", "content": "keep"})
|
||||
session.messages.extend(_tool_turn("keep", 0))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
session.retain_recent_legal_suffix(4)
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
_assert_no_orphans(history)
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[0]["content"] == "keep"
|
||||
|
||||
|
||||
# --- last_archived > 0 ---
|
||||
|
||||
def test_orphan_trim_with_last_archived():
|
||||
@@ -635,6 +584,40 @@ def test_fork_session_allows_index_equal_to_user_count(tmp_path):
|
||||
assert [m["content"] for m in forked.messages] == ["round1", "answer1"]
|
||||
|
||||
|
||||
def test_fork_session_user_index_ignores_hidden_checkpoint_anchor(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
source.add_message("user", "round1")
|
||||
source.add_message("assistant", "answer1")
|
||||
source.add_message("user", "round2")
|
||||
source.add_message(
|
||||
"user",
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
**{HIDDEN_HISTORY_META: True},
|
||||
)
|
||||
source.add_message("assistant", "answer2")
|
||||
source.last_archived = 3
|
||||
source.metadata["_last_summary"] = {"text": "round1 and round2"}
|
||||
manager.save(source)
|
||||
|
||||
forked = manager.fork_session_before_user_index(
|
||||
"websocket:source",
|
||||
"websocket:fork",
|
||||
2,
|
||||
)
|
||||
|
||||
assert forked is not None
|
||||
assert [message["content"] for message in forked.messages] == [
|
||||
"round1",
|
||||
"answer1",
|
||||
"round2",
|
||||
SUMMARY_CONTINUATION_TEXT,
|
||||
"answer2",
|
||||
]
|
||||
assert forked.last_archived == 3
|
||||
assert forked.metadata["_last_summary"]["text"] == "round1 and round2"
|
||||
|
||||
|
||||
def test_fork_session_drops_summary_when_fork_point_is_inside_archived_prefix(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
source = manager.get_or_create("websocket:source")
|
||||
@@ -756,44 +739,6 @@ def test_get_history_recovers_user_when_token_slice_would_be_assistant_only(monk
|
||||
assert [m["content"] for m in history] == ["u2", "a2"]
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_hard_cap_with_long_non_user_chain():
|
||||
session = Session(key="test:hard-cap-chain")
|
||||
session.messages.append({"role": "user", "content": "u0"})
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
for i in range(12):
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
|
||||
session.retain_recent_legal_suffix(6)
|
||||
|
||||
assert len(session.messages) <= 6
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_can_extend_to_user_for_long_recent_turn():
|
||||
session = Session(key="test:extend-to-user")
|
||||
session.messages.append({"role": "user", "content": "old"})
|
||||
session.messages.append({"role": "assistant", "content": "old answer"})
|
||||
session.messages.append({"role": "user", "content": "record this"})
|
||||
for i in range(4):
|
||||
session.messages.extend(_tool_turn("recent", i))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
session.retain_recent_legal_suffix(8, extend_to_user=True)
|
||||
|
||||
assert len(session.messages) > 8
|
||||
assert session.messages[0]["content"] == "record this"
|
||||
assert session.messages[-1]["content"] == "done"
|
||||
history = session.get_history(max_messages=500)
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
def test_get_history_can_extend_to_user_for_long_recent_turn():
|
||||
session = Session(key="test:history-extend-to-user")
|
||||
session.messages.append({"role": "user", "content": "old"})
|
||||
@@ -828,82 +773,3 @@ def test_get_history_extend_to_user_keeps_newer_user_inside_window():
|
||||
|
||||
assert [m["content"] for m in history] == ["new question", "new answer"]
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_dropped_messages():
|
||||
"""retain_recent_legal_suffix returns the actually-dropped messages."""
|
||||
session = Session(
|
||||
key="test:return-dropped",
|
||||
provider_state=ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
provider="openai:test",
|
||||
model="test-model",
|
||||
version=1,
|
||||
payload={"items": []},
|
||||
),
|
||||
)
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert len(result.dropped) == 6
|
||||
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
|
||||
assert len(session.messages) == 4
|
||||
assert result.already_consolidated_count == 0
|
||||
assert session.provider_state is None
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||
"""No messages dropped → empty list returned."""
|
||||
state = ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
provider="openai:test",
|
||||
model="test-model",
|
||||
version=1,
|
||||
payload={"items": []},
|
||||
)
|
||||
session = Session(key="test:no-drop", provider_state=state)
|
||||
for i in range(3):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert result.dropped == []
|
||||
assert result.already_consolidated_count == 0
|
||||
assert len(session.messages) == 3
|
||||
assert session.provider_state is state
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_all_on_zero():
|
||||
"""max_messages=0 clears session and returns all messages."""
|
||||
session = Session(key="test:zero-return")
|
||||
for i in range(5):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
session.last_archived = 3
|
||||
|
||||
result = session.retain_recent_legal_suffix(0)
|
||||
|
||||
assert len(result.dropped) == 5
|
||||
assert result.already_consolidated_count == 3
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_last_archived_correct_in_else_branch():
|
||||
"""last_archived should count retained messages from the old archived prefix."""
|
||||
session = Session(key="test:else-lc-correct")
|
||||
# 20 messages: u0..u9, a0..a9
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"u{i}"})
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
session.last_archived = 12 # u0..u9, a0, a1 archived
|
||||
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
# Retained messages start from latest user (u9) + max_messages forward
|
||||
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
|
||||
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
|
||||
assert session.last_archived == 3
|
||||
# already_cons should count dropped messages with original index < 12
|
||||
assert result.already_consolidated_count == 9
|
||||
|
||||
@@ -114,7 +114,7 @@ async def test_removed_session_model_preset_falls_back_and_clears_metadata(tmp_p
|
||||
provider=base,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=8_000,
|
||||
context_window_tokens=16_000,
|
||||
)
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
session_key = "sdk:removed-preset"
|
||||
@@ -196,7 +196,7 @@ async def test_sdk_custom_model_preset_metadata_does_not_select_runtime(
|
||||
provider=base,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=8_000,
|
||||
context_window_tokens=16_000,
|
||||
)
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
bot = Nanobot(loop)
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
|
||||
def _assert_no_orphans(history: list[dict]) -> None:
|
||||
declared = {
|
||||
tc["id"]
|
||||
for m in history
|
||||
if m.get("role") == "assistant"
|
||||
for tc in (m.get("tool_calls") or [])
|
||||
}
|
||||
orphans = [
|
||||
m.get("tool_call_id")
|
||||
for m in history
|
||||
if m.get("role") == "tool" and m.get("tool_call_id") not in declared
|
||||
]
|
||||
assert orphans == [], f"orphan tool_call_ids: {orphans}"
|
||||
|
||||
|
||||
def _delivery(content: str) -> dict:
|
||||
return {"role": "assistant", "content": content, "_channel_delivery": True}
|
||||
|
||||
|
||||
def _tool_turn(prefix: str, idx: int) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"{prefix}_{idx}_a",
|
||||
"type": "function",
|
||||
"function": {"name": "x", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": f"{prefix}_{idx}_b",
|
||||
"type": "function",
|
||||
"function": {"name": "y", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_a", "name": "x", "content": "ok"},
|
||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_b", "name": "y", "content": "ok"},
|
||||
]
|
||||
|
||||
|
||||
def _contents(messages: list[dict]) -> list[str]:
|
||||
return [m.get("content") for m in messages]
|
||||
|
||||
|
||||
def _has_delivery(messages: list[dict]) -> bool:
|
||||
return any(m.get("_channel_delivery") for m in messages)
|
||||
|
||||
|
||||
# --- Hard-cap trimming must preserve a proactive delivery the user replied to ---
|
||||
|
||||
|
||||
def test_retain_hard_cap_keeps_delivery_before_user():
|
||||
session = Session(key="test:cap-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("Remember to drink water"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
assert _has_delivery(session.messages), "delivery dropped by hard-cap trim"
|
||||
assert _contents(session.messages) == [
|
||||
"Remember to drink water",
|
||||
"ok",
|
||||
"great",
|
||||
]
|
||||
|
||||
|
||||
def test_retain_hard_cap_matches_get_history_boundary():
|
||||
"""The trimmed suffix must start on the same message as get_history()."""
|
||||
session = Session(key="test:cap-boundary")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
||||
session.messages.append({"role": "user", "content": "show them"})
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
expected = session.get_history(max_messages=3)
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
|
||||
|
||||
def test_retain_extend_to_user_keeps_delivery_before_recovered_user():
|
||||
session = Session(key="test:extend-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "work"})
|
||||
session.messages.append(_delivery("Reminder: deploy at 17:00"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
session.messages.append({"role": "assistant", "content": "a3"})
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _has_delivery(session.messages), "delivery dropped by extend_to_user trim"
|
||||
assert session.messages[0]["content"] == "Reminder: deploy at 17:00"
|
||||
assert session.messages[-1]["content"] == "a3"
|
||||
|
||||
|
||||
def test_retain_extend_to_user_matches_get_history_boundary():
|
||||
session = Session(key="test:extend-boundary")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "work"})
|
||||
session.messages.append(_delivery("Reminder: review the draft"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
session.messages.append({"role": "assistant", "content": "a3"})
|
||||
|
||||
expected = session.get_history(max_messages=3, extend_to_user=True)
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
|
||||
|
||||
def test_retain_extend_to_user_does_not_extend_delivery_only_tail():
|
||||
session = Session(key="test:extend-no-user")
|
||||
for i in range(4):
|
||||
session.messages.append(_delivery(f"notification {i}"))
|
||||
|
||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _contents(session.messages) == [
|
||||
"notification 1",
|
||||
"notification 2",
|
||||
"notification 3",
|
||||
]
|
||||
|
||||
|
||||
# --- Only the immediately-preceding delivery is part of the anchor ---
|
||||
|
||||
|
||||
def test_retain_keeps_only_immediate_delivery():
|
||||
session = Session(key="test:multi-delivery")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("old scheduled note"))
|
||||
session.messages.append(_delivery("new scheduled note"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(3)
|
||||
|
||||
kept = _contents(session.messages)
|
||||
assert kept == ["new scheduled note", "ok", "great"], kept
|
||||
|
||||
|
||||
def test_retain_drops_delivery_not_adjacent_to_anchor_user():
|
||||
"""A delivery that does not immediately precede the retained user turn is
|
||||
not part of the anchor and should not be force-retained."""
|
||||
session = Session(key="test:nonadjacent")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("unrelated scheduled note"))
|
||||
session.messages.append({"role": "assistant", "content": "reply"})
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "great"})
|
||||
|
||||
session.retain_recent_legal_suffix(2)
|
||||
|
||||
assert not _has_delivery(session.messages)
|
||||
assert _contents(session.messages) == ["ok", "great"]
|
||||
|
||||
|
||||
def test_compact_probe_keeps_delivery_in_visible_suffix():
|
||||
"""compact_idle_session() trims a probe copy with extend_to_user=True; the
|
||||
visible suffix it keeps must still contain the delivery message."""
|
||||
tail = [
|
||||
{"role": "user", "content": "setup"},
|
||||
{"role": "assistant", "content": "work"},
|
||||
_delivery("Reminder: deploy at 17:00"),
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
{"role": "assistant", "content": "a3"},
|
||||
]
|
||||
probe = Session(key="test:probe", messages=tail)
|
||||
|
||||
probe.retain_recent_legal_suffix(3, extend_to_user=True)
|
||||
|
||||
assert _has_delivery(probe.messages)
|
||||
assert probe.messages[0]["content"] == "Reminder: deploy at 17:00"
|
||||
|
||||
|
||||
# --- Trimming must stay coherent with the rest of replay ---
|
||||
|
||||
|
||||
def test_retain_then_replay_keeps_delivery_and_no_orphans():
|
||||
session = Session(key="test:replay-after-trim")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
||||
session.messages.append({"role": "user", "content": "show them"})
|
||||
session.messages.extend(_tool_turn("cur", 0))
|
||||
session.messages.append({"role": "assistant", "content": "done"})
|
||||
|
||||
session.retain_recent_legal_suffix(6)
|
||||
|
||||
assert _has_delivery(session.messages)
|
||||
history = session.get_history(max_messages=500)
|
||||
_assert_no_orphans(history)
|
||||
assert any(m.get("content") == "You have 3 pending tasks" for m in history)
|
||||
|
||||
|
||||
def test_retain_keeps_delivery_when_user_inside_window():
|
||||
"""When the capped window already contains a user, its immediately
|
||||
preceding delivery must stay attached to it."""
|
||||
session = Session(key="test:window-user")
|
||||
session.messages.append({"role": "user", "content": "setup"})
|
||||
session.messages.append({"role": "assistant", "content": "a0"})
|
||||
session.messages.append(_delivery("Reminder"))
|
||||
session.messages.append({"role": "user", "content": "ok"})
|
||||
session.messages.append({"role": "assistant", "content": "a1"})
|
||||
session.messages.append({"role": "assistant", "content": "a2"})
|
||||
|
||||
expected = session.get_history(max_messages=4)
|
||||
|
||||
session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert _has_delivery(session.messages)
|
||||
assert _contents(session.messages) == _contents(expected)
|
||||
@@ -42,6 +42,65 @@ def _make_loop(*, tools_config=None):
|
||||
return loop, bus
|
||||
|
||||
|
||||
class TestActiveTaskTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_task_removes_empty_session_group(self):
|
||||
loop, _bus = _make_loop()
|
||||
release = asyncio.Event()
|
||||
task = asyncio.create_task(release.wait())
|
||||
|
||||
loop._track_active_task("test:c1", task)
|
||||
release.set()
|
||||
await task
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert "test:c1" not in loop._active_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_group_remains_until_last_task_completes(self):
|
||||
loop, _bus = _make_loop()
|
||||
releases = [asyncio.Event(), asyncio.Event()]
|
||||
tasks = [asyncio.create_task(release.wait()) for release in releases]
|
||||
for task in tasks:
|
||||
loop._track_active_task("test:c1", task)
|
||||
|
||||
releases[0].set()
|
||||
await tasks[0]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert loop._active_tasks["test:c1"] == {tasks[1]}
|
||||
|
||||
releases[1].set()
|
||||
await tasks[1]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert "test:c1" not in loop._active_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_callback_preserves_replacement_session_group(self):
|
||||
loop, _bus = _make_loop()
|
||||
old_release = asyncio.Event()
|
||||
new_release = asyncio.Event()
|
||||
old_task = asyncio.create_task(old_release.wait())
|
||||
new_task = asyncio.create_task(new_release.wait())
|
||||
|
||||
loop._track_active_task("test:c1", old_task)
|
||||
loop._active_tasks.pop("test:c1")
|
||||
loop._track_active_task("test:c1", new_task)
|
||||
|
||||
old_release.set()
|
||||
await old_task
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert loop._active_tasks["test:c1"] == {new_task}
|
||||
|
||||
new_release.set()
|
||||
await new_task
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert "test:c1" not in loop._active_tasks
|
||||
|
||||
|
||||
class TestHandleStop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_no_active_task(self):
|
||||
|
||||
@@ -28,7 +28,7 @@ from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.config.schema import AgentDefaults, Config
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -334,116 +334,6 @@ class TestCmdNewUnifiedSession:
|
||||
assert len(sessions.get_or_create("discord:999").messages) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestConsolidationUnaffectedByUnifiedSession — consolidation is key-agnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsolidationUnaffectedByUnifiedSession:
|
||||
"""maybe_consolidate_by_tokens() behaviour is identical regardless of session key."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_skips_empty_session_for_unified_key(self):
|
||||
"""Empty unified:default session → consolidation exits immediately, archive not called."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
||||
runtime = _runtime(mock_provider)
|
||||
# Use spec= so MagicMock doesn't auto-generate AsyncMock for non-async methods,
|
||||
# which would leave unawaited coroutines and trigger RuntimeWarning.
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
sessions=sessions,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
)
|
||||
consolidator.archive_session = AsyncMock()
|
||||
|
||||
session = Session(key="unified:default")
|
||||
session.messages = []
|
||||
sessions.get_or_create.return_value = session
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
consolidator.archive_session.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_behaviour_identical_for_any_key(self):
|
||||
"""Archive call count is the same for 'telegram:123' and 'unified:default'
|
||||
under identical token conditions."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
archive_calls: dict[str, int] = {}
|
||||
|
||||
for key in ("telegram:123", "unified:default"):
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
||||
runtime = _runtime(mock_provider)
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
sessions=sessions,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
)
|
||||
|
||||
session = Session(key=key)
|
||||
session.messages = [] # empty → exits immediately for both keys
|
||||
sessions.get_or_create.return_value = session
|
||||
|
||||
consolidator.archive_session = AsyncMock()
|
||||
await consolidator.maybe_consolidate_by_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
archive_calls[key] = consolidator.archive_session.call_count
|
||||
|
||||
assert archive_calls["telegram:123"] == archive_calls["unified:default"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_triggers_when_over_budget_unified_key(self):
|
||||
"""When tokens exceed budget, consolidation attempts to find a boundary —
|
||||
behaviour is identical to any other session key."""
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
|
||||
store = MagicMock(spec=MemoryStore)
|
||||
mock_provider = MagicMock()
|
||||
runtime = _runtime(mock_provider)
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
|
||||
consolidator = Consolidator(
|
||||
store=store,
|
||||
sessions=sessions,
|
||||
build_messages=MagicMock(return_value=[]),
|
||||
get_tool_definitions=MagicMock(return_value=[]),
|
||||
)
|
||||
|
||||
session = Session(key="unified:default")
|
||||
session.messages = [{"role": "user", "content": "msg"}]
|
||||
sessions.get_or_create.return_value = session
|
||||
|
||||
# Simulate over-budget: estimated > budget
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
|
||||
# No valid boundary found → returns gracefully without archiving
|
||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=None)
|
||||
consolidator.archive_session = AsyncMock()
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
|
||||
|
||||
# estimate was called (consolidation was attempted)
|
||||
consolidator.estimate_session_prompt_tokens.assert_called_once_with(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
# but archive was not called (no valid boundary)
|
||||
consolidator.archive_session.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestStopCommandWithUnifiedSession — /stop command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
@@ -568,7 +569,10 @@ async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path):
|
||||
loop.runner.run = AsyncMock(side_effect=fake_run)
|
||||
loop.max_iterations = 55
|
||||
|
||||
await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
|
||||
loop.runner.run.assert_awaited_once()
|
||||
|
||||
@@ -609,7 +613,7 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
runtime=runtime,
|
||||
session=None,
|
||||
request_context=RequestContext(channel="test", chat_id="c1", runtime=runtime),
|
||||
@@ -668,7 +672,7 @@ async def test_terminal_drain_timeout(tmp_path):
|
||||
|
||||
runtime = loop.llm_runtime()
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
runtime=runtime,
|
||||
session=session,
|
||||
request_context=RequestContext(
|
||||
@@ -742,7 +746,7 @@ async def test_terminal_drain_reuses_one_timeout_budget(tmp_path):
|
||||
loop.subagents._running_tasks["sub-deadline-1"] = hang_task
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
TranscriptInput(history=[{"role": "user", "content": "test"}], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
session=session,
|
||||
pending_queue=pending_queue,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Regression tests for WebSocket listener health probing portability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket.runtime import WebSocketChannel
|
||||
|
||||
|
||||
class _StubSocket:
|
||||
"""Minimal socket stand-in: real sockets forbid attribute patching."""
|
||||
|
||||
def __init__(self, *, fileno: int, error: OSError | None = None, value: int = 1):
|
||||
self._fileno = fileno
|
||||
self._error = error
|
||||
self._value = value
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self._fileno
|
||||
|
||||
def getsockopt(self, *_args: Any, **_kwargs: Any) -> int:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._value
|
||||
|
||||
|
||||
class _StubServer:
|
||||
"""Minimal server stand-in for the production listener-health boundary."""
|
||||
|
||||
def __init__(self, sock: _StubSocket, *, serving: bool = True):
|
||||
self._sock = sock
|
||||
self._serving = serving
|
||||
|
||||
@property
|
||||
def sockets(self) -> tuple[_StubSocket, ...]:
|
||||
return (self._sock,)
|
||||
|
||||
def is_serving(self) -> bool:
|
||||
return self._serving
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listening_socket() -> socket.socket:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
yield sock
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_real_listening_socket_is_accepting(listening_socket: socket.socket) -> None:
|
||||
"""A genuinely listening socket must never be reported as degraded.
|
||||
|
||||
On macOS/BSD this exercises the ``ENOPROTOOPT`` fallback path; on Linux it
|
||||
exercises the native ``SO_ACCEPTCONN`` path. Both must agree.
|
||||
"""
|
||||
assert WebSocketChannel._socket_is_accepting(listening_socket) is True
|
||||
|
||||
|
||||
def test_closed_socket_is_not_accepting() -> None:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
sock.close()
|
||||
|
||||
assert WebSocketChannel._socket_is_accepting(sock) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsupported_errno",
|
||||
[errno.ENOPROTOOPT, errno.EOPNOTSUPP],
|
||||
)
|
||||
def test_unsupported_sockopt_falls_back_to_fd_liveness(unsupported_errno: int) -> None:
|
||||
"""macOS/BSD reject ``SO_ACCEPTCONN`` even on healthy listeners.
|
||||
|
||||
Treating that rejection as "not serving" made the listener look permanently
|
||||
degraded, so the channel retried forever and never became ready.
|
||||
"""
|
||||
sock = _StubSocket(fileno=3, error=OSError(unsupported_errno, "Protocol not available"))
|
||||
|
||||
assert WebSocketChannel._socket_is_accepting(sock) is True
|
||||
|
||||
|
||||
def test_listener_health_uses_unsupported_sockopt_fallback() -> None:
|
||||
"""The fallback must be wired into the health check that controls readiness."""
|
||||
sock = _StubSocket(fileno=3, error=OSError(errno.ENOPROTOOPT, "Protocol not available"))
|
||||
server: Any = _StubServer(sock)
|
||||
|
||||
assert WebSocketChannel._listener_is_serving(server) is True
|
||||
|
||||
|
||||
def test_unexpected_oserror_propagates() -> None:
|
||||
sock = _StubSocket(fileno=3, error=OSError(errno.EBADF, "Bad file descriptor"))
|
||||
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
WebSocketChannel._socket_is_accepting(sock)
|
||||
|
||||
assert excinfo.value.errno == errno.EBADF
|
||||
|
||||
|
||||
def test_listener_health_rejects_invalid_socket_state() -> None:
|
||||
"""``EINVAL`` can mean that a live socket isn't actually listening."""
|
||||
sock = _StubSocket(fileno=3, error=OSError(errno.EINVAL, "Invalid argument"))
|
||||
server: Any = _StubServer(sock)
|
||||
|
||||
assert WebSocketChannel._listener_is_serving(server) is False
|
||||
|
||||
|
||||
def test_unsupported_sockopt_still_rejects_dead_fd() -> None:
|
||||
"""The portability fallback must not mask an already-closed listener."""
|
||||
sock = _StubSocket(fileno=-1, error=OSError(errno.ENOPROTOOPT, "Protocol not available"))
|
||||
|
||||
assert WebSocketChannel._socket_is_accepting(sock) is False
|
||||
@@ -1837,12 +1837,6 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
||||
assert passed_config.workspace_path == workspace_path
|
||||
|
||||
|
||||
def test_heartbeat_retains_recent_messages_by_default():
|
||||
config = Config()
|
||||
|
||||
assert config.gateway.heartbeat.keep_recent_messages == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content, expected",
|
||||
[
|
||||
@@ -2101,7 +2095,7 @@ def _patch_cli_command_runtime(
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
|
||||
|
||||
|
||||
def test_heartbeat_empty_response_still_retains_recent_messages(
|
||||
def test_heartbeat_empty_response_is_not_evaluated(
|
||||
monkeypatch, tmp_path: Path,
|
||||
) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
@@ -2119,21 +2113,9 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
||||
bus.publish_outbound = AsyncMock()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
class _FakeSession:
|
||||
def retain_recent_legal_suffix(self, limit: int) -> None:
|
||||
seen["retained_limit"] = limit
|
||||
|
||||
class _FakeSessionManager:
|
||||
def __init__(self, _workspace: Path) -> None:
|
||||
self.session = _FakeSession()
|
||||
seen["heartbeat_session"] = self.session
|
||||
|
||||
def get_or_create(self, key: str) -> _FakeSession:
|
||||
seen["session_key"] = key
|
||||
return self.session
|
||||
|
||||
def save(self, session: _FakeSession) -> None:
|
||||
seen["saved_session"] = session
|
||||
pass
|
||||
|
||||
def list_sessions(self) -> list[dict[str, str]]:
|
||||
return [{"key": "telegram:u1"}]
|
||||
@@ -2199,9 +2181,6 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
||||
response = asyncio.run(cron.on_job(CronJob(id="heartbeat", name="heartbeat")))
|
||||
|
||||
assert response is None
|
||||
assert seen["session_key"] == "heartbeat"
|
||||
assert seen["retained_limit"] == config.gateway.heartbeat.keep_recent_messages
|
||||
assert seen["saved_session"] is seen["heartbeat_session"]
|
||||
|
||||
|
||||
def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.providers.base import LLMResponse, LLMUsage
|
||||
|
||||
@@ -311,10 +312,16 @@ class TestRestartCommand:
|
||||
LLMResponse(content="second", usage=None),
|
||||
])
|
||||
|
||||
first = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
first = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
assert first.usage == LLMUsage.reported(input_tokens=9, output_tokens=4)
|
||||
|
||||
second = await loop._run_agent_loop([], runtime=loop.llm_runtime())
|
||||
second = await loop._run_agent_loop(
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
)
|
||||
assert second.usage == LLMUsage.estimated(input_tokens=123, output_tokens=7)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -13,3 +13,12 @@ def test_gateway_restart_mode_accepts_camel_alias():
|
||||
def test_gateway_restart_mode_rejects_unknown_value():
|
||||
with pytest.raises(ValueError):
|
||||
GatewayConfig(restart_mode="service")
|
||||
|
||||
|
||||
def test_heartbeat_ignores_removed_retention_limit():
|
||||
config = Config.model_validate(
|
||||
{"gateway": {"heartbeat": {"keepRecentMessages": 8}}}
|
||||
)
|
||||
|
||||
heartbeat = config.model_dump(by_alias=True)["gateway"]["heartbeat"]
|
||||
assert "keepRecentMessages" not in heartbeat
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
|
||||
|
||||
async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01) -> None:
|
||||
@@ -292,7 +293,12 @@ def test_load_store_migrates_legacy_delivery_context(tmp_path) -> None:
|
||||
"deliver": True,
|
||||
"channel": "telegram",
|
||||
"to": "user-1",
|
||||
"channelMeta": {"message_thread_id": 42},
|
||||
"channelMeta": {
|
||||
"message_thread_id": 42,
|
||||
RUNTIME_CONTEXT_INPUT_META: [
|
||||
{"source": "webui_quote", "content": "stale quote"}
|
||||
],
|
||||
},
|
||||
"sessionKey": "telegram:user-1:topic:42",
|
||||
},
|
||||
"state": {},
|
||||
@@ -411,6 +417,39 @@ def test_add_job_preserves_origin_delivery_context(tmp_path) -> None:
|
||||
assert reloaded.payload.origin_metadata == metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_heals_runtime_context_from_pending_external_add(tmp_path) -> None:
|
||||
"""Flattened runtime blocks from older action files must not be replayed."""
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
external = CronService(store_path)
|
||||
job = external.add_job(
|
||||
name="quoted reminder",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="remember this",
|
||||
origin_metadata={"webui": True},
|
||||
**_bound_chat("quoted"),
|
||||
)
|
||||
|
||||
action_path = tmp_path / "cron" / "action.jsonl"
|
||||
action = json.loads(action_path.read_text(encoding="utf-8"))
|
||||
action["params"]["payload"]["origin_metadata"][RUNTIME_CONTEXT_INPUT_META] = [
|
||||
{"source": "webui_quote", "content": "quoted reply"}
|
||||
]
|
||||
action_path.write_text(json.dumps(action), encoding="utf-8")
|
||||
|
||||
owner = CronService(store_path)
|
||||
await owner.start()
|
||||
try:
|
||||
loaded = owner.get_job(job.id)
|
||||
assert loaded is not None
|
||||
assert loaded.payload.origin_metadata == {"webui": True}
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
assert raw["jobs"][0]["payload"]["originMetadata"] == {"webui": True}
|
||||
finally:
|
||||
owner.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
@@ -146,6 +146,51 @@ def test_controller_uses_governed_messages_for_provider_state_delta() -> None:
|
||||
assert governed_checkpoint.pending_messages[-1]["content"] == "compacted result"
|
||||
|
||||
|
||||
def test_controller_estimates_active_state_plus_pending_delta(monkeypatch) -> None:
|
||||
provider = _provider()
|
||||
current_message = {"role": "user", "content": "new delta"}
|
||||
state = ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
provider="openai:test",
|
||||
model="gpt-5.6",
|
||||
version=1,
|
||||
payload={
|
||||
"items": [{"type": "reasoning", "encrypted_content": "opaque"}],
|
||||
"context_tokens": 450,
|
||||
},
|
||||
pending_messages=[current_message],
|
||||
)
|
||||
controller = ProviderConversationStateController(
|
||||
provider=provider,
|
||||
model="gpt-5.6",
|
||||
messages=[current_message],
|
||||
state=state,
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def estimate(_provider, _model, messages, tools):
|
||||
seen["messages"] = messages
|
||||
seen["tools"] = tools
|
||||
return 100, "test-counter"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.conversation_state.estimate_prompt_tokens_chain",
|
||||
estimate,
|
||||
)
|
||||
|
||||
tokens = controller.estimate_request_context_tokens(
|
||||
[current_message],
|
||||
model_messages=[current_message],
|
||||
tool_definitions=[{"type": "web_search"}],
|
||||
)
|
||||
|
||||
assert tokens == 550
|
||||
assert seen == {
|
||||
"messages": [current_message],
|
||||
"tools": [{"type": "web_search"}],
|
||||
}
|
||||
|
||||
|
||||
def test_transient_response_preserves_only_durable_request_messages() -> None:
|
||||
provider = _provider()
|
||||
current_message = {"role": "user", "content": "continue"}
|
||||
|
||||
@@ -112,14 +112,49 @@ class TestEnforceRoleAlternation:
|
||||
assert result[1]["content"] is None
|
||||
assert result[2]["role"] == "tool"
|
||||
|
||||
def test_non_string_content_uses_latest(self):
|
||||
def test_consecutive_user_messages_preserve_text_before_multimodal_content(self):
|
||||
image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,aW1hZ2U="},
|
||||
}
|
||||
msgs = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "A"}]},
|
||||
{"role": "user", "content": "B"},
|
||||
{"role": "user", "content": "Earlier unanswered question"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [image, {"type": "text", "text": "The error is here"}],
|
||||
},
|
||||
]
|
||||
result = LLMProvider._enforce_role_alternation(msgs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "B"
|
||||
assert result == [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Earlier unanswered question"},
|
||||
image,
|
||||
{"type": "text", "text": "The error is here"},
|
||||
],
|
||||
}]
|
||||
|
||||
def test_consecutive_user_messages_preserve_multimodal_content_before_text(self):
|
||||
image = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,aW1hZ2U="},
|
||||
}
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [image, {"type": "text", "text": "First question"}],
|
||||
},
|
||||
{"role": "user", "content": "Follow-up detail"},
|
||||
]
|
||||
result = LLMProvider._enforce_role_alternation(msgs)
|
||||
assert result == [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
image,
|
||||
{"type": "text", "text": "First question"},
|
||||
{"type": "text", "text": "Follow-up detail"},
|
||||
],
|
||||
}]
|
||||
|
||||
def test_original_messages_not_mutated(self):
|
||||
msgs = [
|
||||
|
||||
@@ -22,7 +22,10 @@ from nanobot.providers.openai_codex_provider import (
|
||||
_request_codex,
|
||||
_should_retry_status,
|
||||
)
|
||||
from nanobot.providers.openai_responses import build_responses_state
|
||||
from nanobot.providers.openai_responses import (
|
||||
build_responses_state,
|
||||
responses_state_items,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
@@ -811,12 +814,33 @@ async def test_codex_compacts_state_at_ninety_percent_before_next_request(
|
||||
)
|
||||
|
||||
assert response.content == "done"
|
||||
assert len(bodies) == 2
|
||||
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
|
||||
assert bodies[1]["input"][-1] == {
|
||||
assert response.provider_compaction_applied is True
|
||||
assert response.provider_compaction_state is not None
|
||||
assert response.provider_compaction_scope == "prior_context"
|
||||
assert responses_state_items(response.provider_compaction_state) == [{
|
||||
"type": "compaction",
|
||||
"encrypted_content": "compacted opaque state",
|
||||
}
|
||||
}]
|
||||
assert len(bodies) == 2
|
||||
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
|
||||
assert not any(
|
||||
item.get("role") == "user"
|
||||
and "new question" in str(item.get("content"))
|
||||
for item in bodies[0]["input"]
|
||||
)
|
||||
assert {
|
||||
"type": "compaction",
|
||||
"encrypted_content": "compacted opaque state",
|
||||
} in bodies[1]["input"]
|
||||
assert bodies[1]["input"].index({
|
||||
"type": "compaction",
|
||||
"encrypted_content": "compacted opaque state",
|
||||
}) < next(
|
||||
index
|
||||
for index, item in enumerate(bodies[1]["input"])
|
||||
if item.get("role") == "user"
|
||||
and "new question" in str(item.get("content"))
|
||||
)
|
||||
assert not any(
|
||||
item.get("type") == "reasoning"
|
||||
for item in bodies[1]["input"]
|
||||
|
||||
@@ -712,6 +712,45 @@ class TestParseResponseOutput:
|
||||
assert result.provider_state is not None
|
||||
assert responses_state_items(result.provider_state) == [*input_items, *output]
|
||||
|
||||
def test_marks_only_a_new_response_compaction(self):
|
||||
compacted = parse_response_output(
|
||||
{
|
||||
"output": [
|
||||
{"type": "compaction", "encrypted_content": "opaque"},
|
||||
{"type": "message", "role": "assistant", "content": "done"},
|
||||
],
|
||||
"status": "completed",
|
||||
"usage": {},
|
||||
},
|
||||
state_provider="openai:test",
|
||||
state_model="gpt-5.6",
|
||||
state_input_items=[{"role": "user", "content": "old"}],
|
||||
)
|
||||
replayed = parse_response_output(
|
||||
{
|
||||
"output": [
|
||||
{"type": "message", "role": "assistant", "content": "continued"},
|
||||
],
|
||||
"status": "completed",
|
||||
"usage": {},
|
||||
},
|
||||
state_provider="openai:test",
|
||||
state_model="gpt-5.6",
|
||||
state_input_items=[
|
||||
{"type": "compaction", "encrypted_content": "opaque"},
|
||||
],
|
||||
)
|
||||
|
||||
assert compacted.provider_compaction_applied is True
|
||||
assert compacted.provider_compaction_state is not None
|
||||
assert compacted.provider_compaction_scope == "current_request"
|
||||
assert responses_state_items(compacted.provider_compaction_state) == [
|
||||
{"type": "compaction", "encrypted_content": "opaque"},
|
||||
]
|
||||
assert replayed.provider_compaction_applied is False
|
||||
assert replayed.provider_compaction_state is None
|
||||
assert replayed.provider_compaction_scope is None
|
||||
|
||||
|
||||
class TestResponsesConversationState:
|
||||
def test_server_compaction_prunes_superseded_prefix(self):
|
||||
|
||||
@@ -141,14 +141,13 @@ def test_internal_continuation_requires_budget_boundary_and_queue():
|
||||
)
|
||||
|
||||
|
||||
def test_save_skip_matches_prefix_when_current_message_merged():
|
||||
def test_save_skip_matches_prefix_when_current_message_was_persisted():
|
||||
skip = _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=2, # [system, merged user]
|
||||
history_count=1,
|
||||
initial_message_count=3, # [system, history user, current user]
|
||||
input_persisted_early=True,
|
||||
)
|
||||
assert skip == 2
|
||||
assert skip == 3
|
||||
|
||||
|
||||
def test_save_skip_unchanged_for_standalone_current_message():
|
||||
@@ -156,12 +155,10 @@ def test_save_skip_unchanged_for_standalone_current_message():
|
||||
assert _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=3,
|
||||
history_count=1,
|
||||
input_persisted_early=True,
|
||||
) == 3
|
||||
assert _save_skip_for_turn(
|
||||
message_metadata=None,
|
||||
initial_message_count=3,
|
||||
history_count=1,
|
||||
input_persisted_early=False,
|
||||
) == 2
|
||||
|
||||
@@ -1412,7 +1412,6 @@ async def test_sessions_ingest_imports_transcript_without_running_model(tmp_path
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
bot._loop.process_direct = AsyncMock()
|
||||
bot._loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
|
||||
snapshot = await bot.sessions.ingest(
|
||||
"sdk:history",
|
||||
@@ -1442,7 +1441,6 @@ async def test_sessions_ingest_imports_transcript_without_running_model(tmp_path
|
||||
assert snapshot.messages[0]["source"] == "longmemeval"
|
||||
assert snapshot.messages[1]["source"] == "longmemeval"
|
||||
bot._loop.process_direct.assert_not_called()
|
||||
bot._loop.consolidator.maybe_consolidate_by_tokens.assert_not_called()
|
||||
|
||||
reloaded = bot.sessions.get("sdk:history")
|
||||
assert reloaded is not None
|
||||
@@ -1635,12 +1633,13 @@ async def test_runtime_helpers_expose_model_workspace_and_compact(tmp_path):
|
||||
runtime = bot._loop.llm_runtime()
|
||||
bot._loop.runtime_for_session = MagicMock(return_value=runtime) # type: ignore[method-assign]
|
||||
|
||||
bot._loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
compact_session = AsyncMock()
|
||||
bot._loop.consolidator.compact_idle_session = compact_session
|
||||
snapshot = await bot.runtime.compact_session("sdk:history")
|
||||
assert snapshot.key == "sdk:history"
|
||||
assert (
|
||||
bot._loop.consolidator.maybe_consolidate_by_tokens.await_args.kwargs["runtime"]
|
||||
is runtime
|
||||
compact_session.assert_awaited_once_with(
|
||||
"sdk:history",
|
||||
runtime=runtime,
|
||||
)
|
||||
assert bot.runtime.model == bot._loop.model
|
||||
assert bot.runtime.workspace == tmp_path
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -11,6 +12,7 @@ from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, RuntimeContextBlock
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
@@ -299,6 +301,41 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
|
||||
assert jobs[0].payload.origin_metadata == {"webui": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_tool_snapshots_only_persistable_request_metadata(tmp_path) -> None:
|
||||
"""Live runtime context must not poison a persisted WebUI cron job."""
|
||||
store_path = tmp_path / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
tool = CronTool(service)
|
||||
await service.start()
|
||||
try:
|
||||
with request_context(
|
||||
RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-123",
|
||||
metadata={
|
||||
"webui": True,
|
||||
RUNTIME_CONTEXT_INPUT_META: [
|
||||
RuntimeContextBlock(source="webui_quote", content="quoted reply")
|
||||
],
|
||||
"opaque": object(),
|
||||
},
|
||||
session_key=UNIFIED_SESSION_KEY,
|
||||
)
|
||||
):
|
||||
result = await tool.execute(action="add", message="standup", every_seconds=300)
|
||||
|
||||
assert result.startswith("Created job")
|
||||
jobs = service.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.origin_metadata == {"webui": True}
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
assert raw["jobs"][0]["payload"]["originMetadata"] == {"webui": True}
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
|
||||
"""Channel-provided thread session keys should remain the cron owner."""
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import TranscriptInput
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
@@ -178,7 +179,9 @@ class TestMessageToolSuppressLogic:
|
||||
progress.append((content, tool_hint))
|
||||
|
||||
result = await loop._run_agent_loop(
|
||||
[], runtime=loop.llm_runtime(), on_progress=on_progress
|
||||
TranscriptInput(history=[], current_message=None),
|
||||
runtime=loop.llm_runtime(),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
assert result.final_content == "Done"
|
||||
|
||||
@@ -183,6 +183,66 @@ async def test_rate_limit_is_per_source_session_and_uses_a_rolling_minute(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_releases_expired_source_state_and_keeps_recent_sources(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
_persist(
|
||||
sessions,
|
||||
"websocket:a",
|
||||
"websocket:b",
|
||||
"websocket:c",
|
||||
"websocket:target",
|
||||
)
|
||||
now = 0.0
|
||||
tool = SendSessionMessageTool(
|
||||
sessions=sessions,
|
||||
bus=MessageBus(),
|
||||
max_messages_per_minute=2,
|
||||
clock=lambda: now,
|
||||
)
|
||||
target = _handle(sessions, "websocket:target").name
|
||||
|
||||
for source in ("websocket:a", "websocket:b"):
|
||||
await tool.enqueue(
|
||||
source_session_key=source,
|
||||
target_handle=target,
|
||||
content="initial",
|
||||
expect_reply=False,
|
||||
)
|
||||
now = 30.0
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="recent",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
now = 61.0
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:c",
|
||||
target_handle=target,
|
||||
content="trigger cleanup",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
assert set(tool._sent_at) == {"websocket:a", "websocket:c"}
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="within rolling window",
|
||||
expect_reply=False,
|
||||
)
|
||||
with pytest.raises(SessionMessageError, match="rate limit"):
|
||||
await tool.enqueue(
|
||||
source_session_key="websocket:a",
|
||||
target_handle=target,
|
||||
content="over limit",
|
||||
expect_reply=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_timeout_injects_a_user_input_back_into_the_source(
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -9,7 +9,9 @@ from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
def test_coding_tool_descriptions_steer_editing_priority() -> None:
|
||||
apply_patch = ApplyPatchTool().description.lower()
|
||||
edit_file = EditFileTool().description.lower()
|
||||
edit_tool = EditFileTool()
|
||||
edit_file = edit_tool.description.lower()
|
||||
edit_parameters = edit_tool.parameters["properties"]
|
||||
write_file = WriteFileTool().description.lower()
|
||||
|
||||
assert "default tool for code edits" in apply_patch
|
||||
@@ -18,8 +20,10 @@ def test_coding_tool_descriptions_steer_editing_priority() -> None:
|
||||
assert "edit_file only for small exact replacements" in apply_patch
|
||||
|
||||
assert "small, exact replacement" in edit_file
|
||||
assert "copied from read_file" in edit_file
|
||||
assert "prefer apply_patch" in edit_file
|
||||
assert "occurrence, line_hint, and replace_all=true are mutually exclusive" in edit_file
|
||||
assert "copy it from read_file" in edit_parameters["old_text"]["description"].lower()
|
||||
assert "must differ from old_text" in edit_parameters["new_text"]["description"].lower()
|
||||
|
||||
assert "replace an entire file" in write_file
|
||||
assert "prefer apply_patch" in write_file
|
||||
|
||||
@@ -287,6 +287,33 @@ def test_create_model_configuration_accepts_legacy_label_without_changing_call_o
|
||||
assert duplicate.value.status == 409
|
||||
|
||||
|
||||
def test_first_model_configuration_replaces_unused_schema_default(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.providers.openai.api_key = "sk-test"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = create_model_configuration(
|
||||
{
|
||||
"name": ["openai"],
|
||||
"provider": ["openai"],
|
||||
"model": ["openai/gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["model_call_order"] == ["openai"]
|
||||
assert payload["model_call_order_editable"] is True
|
||||
assert payload["agent"]["model_preset"] == "openai"
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset == "openai"
|
||||
assert saved.agents.defaults.fallback_models == []
|
||||
assert saved.model_presets["openai"].model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_create_model_configuration_preserves_canonical_name(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -337,7 +364,7 @@ def test_create_model_configuration_accepts_dynamic_custom_provider(
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "default"
|
||||
assert payload["agent"]["model_preset"] == "tenant-model"
|
||||
assert payload["created_model_preset"] == "tenant-model"
|
||||
saved = load_config(config_path)
|
||||
assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME
|
||||
@@ -565,7 +592,7 @@ def test_update_model_call_order_sets_primary_and_fallbacks(
|
||||
assert saved.agents.defaults.fallback_models == ["primary"]
|
||||
|
||||
|
||||
def test_update_model_call_order_requires_named_primary(
|
||||
def test_update_model_call_order_activates_existing_named_preset(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -575,11 +602,36 @@ def test_update_model_call_order_requires_named_primary(
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = update_model_call_order({"order": [json.dumps(["backup"])]})
|
||||
|
||||
assert payload["model_call_order"] == ["backup"]
|
||||
assert payload["model_call_order_editable"] is True
|
||||
assert load_config(config_path).agents.defaults.model_preset == "backup"
|
||||
|
||||
|
||||
def test_update_model_call_order_preserves_real_legacy_configuration(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.providers.openai.api_key = "sk-test"
|
||||
config.agents.defaults.model = "openai/gpt-4o"
|
||||
config.agents.defaults.provider = "openai"
|
||||
config.model_presets["backup"] = ModelPresetConfig(
|
||||
model="openai/gpt-4.1-mini",
|
||||
provider="openai",
|
||||
)
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError) as error:
|
||||
update_model_call_order({"order": [json.dumps(["backup"])]})
|
||||
|
||||
assert error.value.status == 409
|
||||
assert load_config(config_path).agents.defaults.model_preset is None
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset is None
|
||||
assert saved.agents.defaults.model == "openai/gpt-4o"
|
||||
|
||||
|
||||
def test_migrate_model_configurations_preserves_legacy_chain(
|
||||
@@ -604,6 +656,7 @@ def test_migrate_model_configurations_preserves_legacy_chain(
|
||||
legacy_payload = settings_payload()
|
||||
assert legacy_payload["model_call_order"] == []
|
||||
assert legacy_payload["model_call_order_editable"] is False
|
||||
assert legacy_payload["model_configuration_migratable"] is True
|
||||
|
||||
payload = migrate_model_configurations()
|
||||
|
||||
@@ -621,6 +674,27 @@ def test_migrate_model_configurations_preserves_legacy_chain(
|
||||
assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"}
|
||||
|
||||
|
||||
def test_schema_default_is_not_exposed_or_materialized_as_legacy_configuration(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["model_configuration_migratable"] is False
|
||||
assert payload["model_call_order_editable"] is False
|
||||
with pytest.raises(WebUISettingsError) as error:
|
||||
migrate_model_configurations()
|
||||
|
||||
assert error.value.status == 409
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset is None
|
||||
assert saved.model_presets == {}
|
||||
|
||||
|
||||
def test_model_configuration_advanced_options_round_trip(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -2288,7 +2362,7 @@ def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "default"
|
||||
assert payload["agent"]["model_preset"] == "codex"
|
||||
assert payload["created_model_preset"] == "codex"
|
||||
saved = load_config(config_path)
|
||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||
@@ -2376,7 +2450,7 @@ def test_create_model_configuration_accepts_azure_openai_aad_mode(
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "default"
|
||||
assert payload["agent"]["model_preset"] == "azure-aad"
|
||||
assert payload["created_model_preset"] == "azure-aad"
|
||||
saved = load_config(config_path)
|
||||
assert saved.model_presets["azure-aad"].provider == "azure_openai"
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_model_domain_owns_dto_and_config_updates() -> None:
|
||||
"model_presets",
|
||||
"model_call_order",
|
||||
"model_call_order_editable",
|
||||
"model_configuration_migratable",
|
||||
"providers",
|
||||
}
|
||||
assert payload["agent"]["model"] == "openai/gpt-5.4"
|
||||
|
||||
+36
-28
@@ -205,7 +205,7 @@ describe("NanobotTui layout", () => {
|
||||
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
|
||||
expect(occurrences(frame, "Ready")).toBe(0)
|
||||
expect(occurrences(frame, "Getting ready…")).toBe(1)
|
||||
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
|
||||
expect(occurrences(frame, "default ▾")).toBe(1)
|
||||
}
|
||||
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
@@ -253,6 +253,23 @@ describe("NanobotTui layout", () => {
|
||||
expect(sent).toEqual(["你好"])
|
||||
})
|
||||
|
||||
test("keeps input typed immediately after Enter in the next draft", async () => {
|
||||
const sent: string[] = []
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
const app = mount(setup, sent)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
await Bun.sleep(1)
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
|
||||
composer.setText("first")
|
||||
setup.mockInput.pressEnter()
|
||||
for (const key of "next") setup.mockInput.pressKey(key)
|
||||
await waitUntil(() => sent.length > 0)
|
||||
|
||||
expect(sent).toEqual(["first"])
|
||||
expect(composer.plainText).toBe("next")
|
||||
})
|
||||
|
||||
test("inserts newlines with Shift+Enter and the universal Ctrl+J fallback", async () => {
|
||||
const sent: string[] = []
|
||||
setup = await createRenderer({
|
||||
@@ -985,7 +1002,6 @@ describe("NanobotTui layout", () => {
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
sessionMenu: { visible: boolean }
|
||||
titleText: { plainText: string }
|
||||
runtimeControls: { modelText: { plainText: string } }
|
||||
}
|
||||
|
||||
@@ -999,8 +1015,7 @@ describe("NanobotTui layout", () => {
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => attached.length === 1)
|
||||
expect(attached).toEqual(["other"])
|
||||
expect(ui.titleText.plainText).toContain("Release checklist")
|
||||
expect(ui.runtimeControls.modelText.plainText).toContain("Deep Research")
|
||||
expect(ui.runtimeControls.modelText.plainText).toBe("Deep Research ▾")
|
||||
expect(ui.runtimeControls.modelText.plainText).not.toContain("test/model")
|
||||
|
||||
app.accept({ event: "attached", chat_id: "other" })
|
||||
@@ -1009,8 +1024,7 @@ describe("NanobotTui layout", () => {
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => newChats.length === 1)
|
||||
expect(newChats).toEqual(["new"])
|
||||
expect(ui.titleText.plainText).toContain("New chat")
|
||||
expect(ui.runtimeControls.modelText.plainText).toContain("test/model")
|
||||
expect(ui.runtimeControls.modelText.plainText).toBe("default ▾")
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
@@ -1182,7 +1196,8 @@ describe("NanobotTui layout", () => {
|
||||
model_preset: "Codex",
|
||||
})
|
||||
await setup.flush()
|
||||
expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6")
|
||||
expect(ui.runtimeControls.modelText.plainText).toBe("Codex ▾")
|
||||
expect(ui.runtimeControls.modelText.plainText).not.toContain("openai/gpt-5.6")
|
||||
|
||||
app.accept({
|
||||
event: "runtime_model_updated",
|
||||
@@ -1190,7 +1205,7 @@ describe("NanobotTui layout", () => {
|
||||
model_preset: "DeepSeek",
|
||||
})
|
||||
await setup.flush()
|
||||
expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6")
|
||||
expect(ui.runtimeControls.modelText.plainText).toBe("Codex ▾")
|
||||
expect(ui.runtimeControls.modelText.plainText).not.toContain("DeepSeek")
|
||||
})
|
||||
|
||||
@@ -1212,8 +1227,8 @@ describe("NanobotTui layout", () => {
|
||||
})
|
||||
await setup.flush()
|
||||
|
||||
expect(ui.runtimeControls.modelText.plainText).toContain("deepseek/deepseek-chat")
|
||||
expect(ui.runtimeControls.modelText.plainText).not.toContain("Codex")
|
||||
expect(ui.runtimeControls.modelText.plainText).toBe("default ▾")
|
||||
expect(ui.runtimeControls.modelText.plainText).not.toContain("deepseek/deepseek-chat")
|
||||
})
|
||||
|
||||
test("refreshes the canonical preset after the model command completes", async () => {
|
||||
@@ -1309,7 +1324,6 @@ describe("NanobotTui layout", () => {
|
||||
menuRoot: { getChildren(): unknown[] }
|
||||
}
|
||||
composer: TextareaRenderable
|
||||
titleText: TextRenderable
|
||||
status: TextRenderable
|
||||
meta: TextRenderable
|
||||
}
|
||||
@@ -1324,7 +1338,6 @@ describe("NanobotTui layout", () => {
|
||||
expect(ui.runtimeControls.modelText.selectable).toBe(false)
|
||||
expect(ui.runtimeControls.accessText.selectable).toBe(false)
|
||||
expect(ui.runtimeControls.contextText.selectable).toBe(false)
|
||||
expect(ui.titleText.selectable).toBe(false)
|
||||
expect(ui.status.selectable).toBe(false)
|
||||
expect(ui.meta.selectable).toBe(false)
|
||||
app.accept({ event: "goal_status", chat_id: "chat", status: "running" })
|
||||
@@ -1394,7 +1407,7 @@ describe("NanobotTui layout", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("opens and switches sessions from the clickable title", async () => {
|
||||
test("switches sessions only through the sessions command", async () => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = ((input: string | URL | Request) => {
|
||||
const url = String(input)
|
||||
@@ -1420,14 +1433,18 @@ describe("NanobotTui layout", () => {
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
sessionMenu: { visible: boolean; root: { getChildren(): unknown[] } }
|
||||
titleText: TextRenderable
|
||||
status: TextRenderable
|
||||
title: { getChildren(): unknown[] }
|
||||
}
|
||||
|
||||
try {
|
||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||
await setup.renderOnce()
|
||||
await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y)
|
||||
const titleItems = ui.title.getChildren() as TextRenderable[]
|
||||
expect(titleItems.some((item) => item.id === "nanobot-tui-title-text")).toBe(false)
|
||||
expect(ui.sessionMenu.visible).toBe(false)
|
||||
|
||||
ui.composer.setText("/sessions")
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => ui.sessionMenu.visible)
|
||||
await setup.flush()
|
||||
expect(ui.composer.placeholder).toBe("Search sessions")
|
||||
@@ -1441,15 +1458,6 @@ describe("NanobotTui layout", () => {
|
||||
expect(attached).toEqual(["other"])
|
||||
expect(ui.sessionMenu.visible).toBe(false)
|
||||
expect(ui.composer.focused).toBe(true)
|
||||
expect(ui.titleText.plainText).toContain("Release checklist")
|
||||
|
||||
app.accept({ event: "attached", chat_id: "other" })
|
||||
await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y)
|
||||
await waitUntil(() => ui.sessionMenu.visible)
|
||||
ui.composer.blur()
|
||||
await setup.mockMouse.click(ui.status.x, ui.status.y)
|
||||
expect(ui.sessionMenu.visible).toBe(false)
|
||||
expect(ui.composer.focused).toBe(true)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
@@ -1715,7 +1723,7 @@ describe("NanobotTui layout", () => {
|
||||
await setup.flush()
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("Release checklist")
|
||||
expect(occurrences(frame, "Current chat")).toBe(1)
|
||||
expect(occurrences(frame, "Current chat")).toBe(0)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
@@ -1959,7 +1967,7 @@ describe("NanobotTui layout", () => {
|
||||
} else if (width >= 28 && height >= 9) {
|
||||
expect(occurrences(frame, "Enter now · Tab next")).toBe(1)
|
||||
}
|
||||
expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0)
|
||||
expect(occurrences(frame, "default ▾")).toBe(height >= 14 ? 1 : 0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3122,7 +3130,7 @@ describe("NanobotTui with a Herdr pane title reporter", () => {
|
||||
await setup.flush()
|
||||
const activeFrame = setup.captureCharFrame()
|
||||
expect(activeFrame).toContain(">_ nanobot")
|
||||
expect(activeFrame).toContain("test/model")
|
||||
expect(activeFrame).toContain("default ▾")
|
||||
expect(occurrences(activeFrame, "› Ship the Herdr integration")).toBe(1)
|
||||
expect(occurrences(activeFrame, "app.ts")).toBe(1)
|
||||
expect(ui.composer.placeholder).toBe("Enter send now · Tab send next")
|
||||
|
||||
+22
-44
@@ -94,7 +94,7 @@ import {
|
||||
type FooterMode,
|
||||
type FooterHintTheme,
|
||||
} from "./footer-hints"
|
||||
import { createTuiHost, type TuiHost } from "./host"
|
||||
import { configureOpenTuiEnvironment, createTuiHost, type TuiHost } from "./host"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl?: string
|
||||
@@ -442,7 +442,6 @@ export class NanobotTui {
|
||||
private readonly client: ChatClient
|
||||
private readonly shell: BoxRenderable
|
||||
private readonly title: BoxRenderable
|
||||
private readonly titleText: TextRenderable
|
||||
private readonly composerFrame: BoxRenderable
|
||||
private readonly composer: TextareaRenderable
|
||||
private composerSyntax: SyntaxStyle
|
||||
@@ -649,28 +648,6 @@ export class NanobotTui {
|
||||
alignItems: "center",
|
||||
backgroundColor: RGBA.defaultBackground(),
|
||||
})
|
||||
this.titleText = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-title-text",
|
||||
content: "nanobot",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
truncate: true,
|
||||
fg: this.palette.muted,
|
||||
selectable: false,
|
||||
onMouseOver: () => { this.titleText.fg = this.palette.accent },
|
||||
onMouseOut: () => this.renderTitleColor(),
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.renderer.clearSelection()
|
||||
if (this.sessionLoading || this.sessionMenu.visible) {
|
||||
this.closeSessions()
|
||||
return
|
||||
}
|
||||
void this.openSessions()
|
||||
},
|
||||
})
|
||||
this.runtimeControls = new RuntimeControls(
|
||||
renderer,
|
||||
runtimeControlsTheme(this.palette),
|
||||
@@ -703,7 +680,6 @@ export class NanobotTui {
|
||||
},
|
||||
},
|
||||
)
|
||||
this.title.add(this.titleText)
|
||||
this.title.add(this.runtimeControls.modelText)
|
||||
this.title.add(this.runtimeControls.accessText)
|
||||
this.title.add(this.runtimeControls.contextText)
|
||||
@@ -757,7 +733,10 @@ export class NanobotTui {
|
||||
// IMEs may commit their final composed glyph after Enter. Matching the
|
||||
// OpenCode/OpenTUI integration, defer twice before reading plainText.
|
||||
onSubmit: () => this.deferSubmit(),
|
||||
onPaste: (event) => this.handlePaste(event),
|
||||
onPaste: (event) => {
|
||||
this.flushSubmit()
|
||||
if (!this.composer.isDestroyed) this.handlePaste(event)
|
||||
},
|
||||
})
|
||||
this.status = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-status",
|
||||
@@ -820,6 +799,7 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
static async create(options: AppOptions): Promise<NanobotTui> {
|
||||
configureOpenTuiEnvironment()
|
||||
const host = createTuiHost()
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
@@ -878,12 +858,15 @@ export class NanobotTui {
|
||||
if (this.submitPending) return
|
||||
this.submitPending = true
|
||||
const generation = ++this.submitGeneration
|
||||
setTimeout(() => setTimeout(() => {
|
||||
if (generation !== this.submitGeneration) return
|
||||
setTimeout(() => setTimeout(() => this.flushSubmit(generation), 0), 0)
|
||||
}
|
||||
|
||||
private flushSubmit(generation = this.submitGeneration): void {
|
||||
if (!this.submitPending || generation !== this.submitGeneration) return
|
||||
this.submitPending = false
|
||||
this.submitGeneration += 1
|
||||
if (this.composer.isDestroyed) return
|
||||
this.submit()
|
||||
}, 0), 0)
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
@@ -1598,6 +1581,15 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private handleKey = (key: KeyEvent): void => {
|
||||
// The app receives keypresses before the focused Textarea. Seal the pending
|
||||
// submission first so this key is inserted into the next draft.
|
||||
if (this.submitPending) {
|
||||
this.flushSubmit()
|
||||
if (this.quitting || this.composer.isDestroyed) {
|
||||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.diffViewer.visible) {
|
||||
if (key.ctrl && key.name === "c") {
|
||||
const selected = this.renderer.getSelection()?.getSelectedText()
|
||||
@@ -1876,7 +1868,6 @@ export class NanobotTui {
|
||||
this.composer.syntaxStyle = this.composerSyntax
|
||||
this.syncComposerImageHighlights(this.composer.plainText)
|
||||
void this.renderer.idle().catch(() => {}).finally(() => previousComposerSyntax.destroy())
|
||||
this.renderTitleColor()
|
||||
this.status.fg = this.palette.muted
|
||||
this.meta.fg = this.palette.faint
|
||||
this.updateMeta()
|
||||
@@ -1956,24 +1947,15 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private updateTitle(): void {
|
||||
const identity = this.sessionTitle.trim() || "nanobot"
|
||||
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
|
||||
this.titleText.content = identity
|
||||
const context = this.contextTokens === null
|
||||
? ""
|
||||
: ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
|
||||
: ` ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
|
||||
? `/${formatTokenCount(this.contextWindowTokens)}`
|
||||
: ""} ctx`
|
||||
this.runtimeControls.updateModel(this.modelName, this.modelPreset)
|
||||
this.runtimeControls.updateContext(context)
|
||||
}
|
||||
|
||||
private renderTitleColor(): void {
|
||||
this.titleText.fg = this.sessionLoading || this.sessionMenu.visible
|
||||
? this.palette.accent
|
||||
: this.palette.muted
|
||||
}
|
||||
|
||||
private resizeComposer(): void {
|
||||
const verticalPadding = this.renderer.height >= 12 ? 1 : 0
|
||||
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
|
||||
@@ -2384,7 +2366,6 @@ export class NanobotTui {
|
||||
this.contextPanel.hide()
|
||||
this.clearComposer()
|
||||
this.sessionLoading = true
|
||||
this.renderTitleColor()
|
||||
const loadId = ++this.sessionLoadId
|
||||
this.status.content = "Loading sessions…"
|
||||
try {
|
||||
@@ -2411,7 +2392,6 @@ export class NanobotTui {
|
||||
this.defaultModelPreset,
|
||||
)
|
||||
this.startSessionRefresh()
|
||||
this.renderTitleColor()
|
||||
this.sessionMenu.update(this.composer.plainText, limit)
|
||||
this.syncComposerPlaceholder()
|
||||
this.updateMeta()
|
||||
@@ -2419,7 +2399,6 @@ export class NanobotTui {
|
||||
} catch (error) {
|
||||
if (loadId !== this.sessionLoadId) return
|
||||
this.sessionLoading = false
|
||||
this.renderTitleColor()
|
||||
this.status.content = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
@@ -2578,7 +2557,6 @@ export class NanobotTui {
|
||||
this.sessionLoadId += 1
|
||||
this.sessionLoading = false
|
||||
this.hideSessionMenu()
|
||||
this.renderTitleColor()
|
||||
this.clearComposer()
|
||||
this.syncComposerPlaceholder()
|
||||
this.composer.focus()
|
||||
|
||||
+25
-1
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTuiHost } from "./host"
|
||||
import {
|
||||
configureOpenTuiEnvironment,
|
||||
createTuiHost,
|
||||
} from "./host"
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Bun.sleep(0)
|
||||
@@ -8,6 +11,27 @@ async function settle(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("TUI host integration", () => {
|
||||
test("disables the explicit-width probe on Windows", () => {
|
||||
const environment: Record<string, string | undefined> = {}
|
||||
|
||||
configureOpenTuiEnvironment(environment, "win32")
|
||||
|
||||
expect(environment.OPENTUI_FORCE_EXPLICIT_WIDTH).toBe("false")
|
||||
})
|
||||
|
||||
test("preserves explicit probe choices and leaves other platforms unchanged", () => {
|
||||
const overridden = {
|
||||
OPENTUI_FORCE_EXPLICIT_WIDTH: "true",
|
||||
}
|
||||
const nonWindows: Record<string, string | undefined> = {}
|
||||
|
||||
configureOpenTuiEnvironment(overridden, "win32")
|
||||
configureOpenTuiEnvironment(nonWindows, "linux")
|
||||
|
||||
expect(overridden.OPENTUI_FORCE_EXPLICIT_WIDTH).toBe("true")
|
||||
expect(nonWindows.OPENTUI_FORCE_EXPLICIT_WIDTH).toBeUndefined()
|
||||
})
|
||||
|
||||
test("standalone terminals remain a no-op", async () => {
|
||||
const commands: string[][] = []
|
||||
const host = createTuiHost({}, async (command) => { commands.push([...command]) })
|
||||
|
||||
@@ -8,6 +8,19 @@ type CommandRunner = (command: readonly string[]) => Promise<void>
|
||||
|
||||
const METADATA_SOURCE = "nanobot:tui:metadata"
|
||||
|
||||
export function configureOpenTuiEnvironment(
|
||||
environment: Environment = process.env,
|
||||
platform = process.platform,
|
||||
): void {
|
||||
if (platform !== "win32") return
|
||||
|
||||
// OpenTUI probes OSC 66 support on the main screen before its renderer is
|
||||
// active. Some Windows terminal hosts do not restore the cursor around that
|
||||
// probe, so shutdown resumes in terminal history instead of below the TUI.
|
||||
// Keep an explicit user choice, but use the safe default on Windows.
|
||||
environment.OPENTUI_FORCE_EXPLICIT_WIDTH ??= "false"
|
||||
}
|
||||
|
||||
class StandaloneHost implements TuiHost {
|
||||
reportTitle(): void {}
|
||||
release(): void {}
|
||||
|
||||
@@ -309,12 +309,9 @@ export class RuntimeControls {
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const runtime = this.modelPreset !== "default"
|
||||
? [this.modelPreset, this.model].filter(Boolean).join(" · ")
|
||||
: this.model
|
||||
this.modelText.content = ` · ${runtime} ▾`
|
||||
this.modelText.content = `${this.modelPreset} ▾`
|
||||
const access = this.scope.access_mode === "full" ? "full access" : "workspace access"
|
||||
this.accessText.content = ` · ${access} ▾`
|
||||
this.accessText.content = ` ${access} ▾`
|
||||
this.renderColors()
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ export class Transcript {
|
||||
const title = this.createText(`>_ nanobot v${options.version}`, "text", true)
|
||||
const context = this.createText([
|
||||
"",
|
||||
`${options.model} · ${options.access}`,
|
||||
`${options.model} ${options.access}`,
|
||||
options.workspace,
|
||||
].join("\n"), "muted")
|
||||
row.add(title)
|
||||
|
||||
@@ -62,6 +62,26 @@ function modelPresetValue(payload: SettingsPayload): string {
|
||||
);
|
||||
}
|
||||
|
||||
function suggestedPresetName(
|
||||
model: string,
|
||||
presets: SettingsPayload["model_presets"],
|
||||
): string {
|
||||
const modelName = model.trim().split("/").filter(Boolean).at(-1) ?? "";
|
||||
const base = (modelName.toLowerCase() === "default" ? "model" : modelName).slice(0, 48);
|
||||
if (!base) return "";
|
||||
|
||||
const existing = new Set(
|
||||
presets.filter((preset) => !preset.is_default).map((preset) => preset.name.toLowerCase()),
|
||||
);
|
||||
if (!existing.has(base.toLowerCase())) return base;
|
||||
|
||||
for (let index = 2; ; index += 1) {
|
||||
const suffix = ` ${index}`;
|
||||
const candidate = `${base.slice(0, 48 - suffix.length)}${suffix}`;
|
||||
if (!existing.has(candidate.toLowerCase())) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
||||
model: "",
|
||||
provider: "",
|
||||
@@ -211,6 +231,7 @@ export function ModelsSettings({
|
||||
t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const presetNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestedPresetNameRef = useRef<string | null>(null);
|
||||
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||
@@ -219,6 +240,9 @@ export function ModelsSettings({
|
||||
useEffect(() => {
|
||||
if (presetNameError) presetNameInputRef.current?.focus();
|
||||
}, [presetNameError]);
|
||||
useEffect(() => {
|
||||
if (!creating) suggestedPresetNameRef.current = null;
|
||||
}, [creating]);
|
||||
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
|
||||
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||
@@ -348,7 +372,10 @@ export function ModelsSettings({
|
||||
<div
|
||||
id="model-preset-editor"
|
||||
data-testid="model-preset-editor"
|
||||
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-floating border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
|
||||
className={cn(
|
||||
"mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-floating border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl",
|
||||
creating && "mt-3",
|
||||
)}
|
||||
>
|
||||
{creating ? (
|
||||
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
|
||||
@@ -377,8 +404,9 @@ export function ModelsSettings({
|
||||
aria-invalid={Boolean(presetNameError)}
|
||||
aria-describedby={presetNameError ? "model-preset-name-error" : undefined}
|
||||
value={form.modelPreset}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||
placeholder={tx("settings.models.presetNamePlaceholder", "e.g. Fast writing")}
|
||||
onChange={(event) => {
|
||||
suggestedPresetNameRef.current = null;
|
||||
onClearPresetNameError();
|
||||
setForm((prev) => ({ ...prev, modelPreset: event.target.value }));
|
||||
}}
|
||||
@@ -405,13 +433,21 @@ export function ModelsSettings({
|
||||
value={providerValue}
|
||||
emptyLabel={t("settings.byok.noConfiguredProviders")}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(provider) =>
|
||||
onChange={(provider) => {
|
||||
const providerChanged = provider !== form.provider;
|
||||
const clearSuggestedName =
|
||||
creating &&
|
||||
providerChanged &&
|
||||
suggestedPresetNameRef.current !== null &&
|
||||
form.modelPreset === suggestedPresetNameRef.current;
|
||||
if (clearSuggestedName) suggestedPresetNameRef.current = null;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
model: provider === prev.provider ? prev.model : "",
|
||||
}))
|
||||
}
|
||||
modelPreset: clearSuggestedName ? "" : prev.modelPreset,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{selectedProviderNeedsSignIn ? (
|
||||
@@ -445,7 +481,20 @@ export function ModelsSettings({
|
||||
provider={form.provider}
|
||||
value={form.model}
|
||||
showProviderLogos={showBrandLogos}
|
||||
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
|
||||
onChange={(model) => {
|
||||
const canSuggestName =
|
||||
creating &&
|
||||
(!form.modelPreset.trim() || form.modelPreset === suggestedPresetNameRef.current);
|
||||
const suggestion = canSuggestName
|
||||
? suggestedPresetName(model, settings.model_presets)
|
||||
: "";
|
||||
if (canSuggestName) suggestedPresetNameRef.current = suggestion;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
model,
|
||||
modelPreset: canSuggestName ? suggestion : prev.modelPreset,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<button
|
||||
@@ -560,7 +609,8 @@ export function ModelsSettings({
|
||||
{tx("settings.models.presets", "Model presets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsGroup>
|
||||
{!settings.model_call_order_editable ? (
|
||||
{!settings.model_call_order_editable &&
|
||||
settings.model_configuration_migratable !== false ? (
|
||||
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-control bg-muted text-muted-foreground">
|
||||
@@ -799,12 +849,10 @@ export function ModelsSettings({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
{!creating ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-[58px] w-full items-center justify-between gap-3 px-4 py-3 text-left outline-none transition-colors hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 sm:px-5"
|
||||
disabled={callOrderBusy}
|
||||
onClick={() => {
|
||||
setEditorRowKey(null);
|
||||
@@ -812,12 +860,10 @@ export function ModelsSettings({
|
||||
onBeginCreate();
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center text-[13px] font-medium">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.models.newPreset", "New model preset")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</span>
|
||||
{orderSaving ? (
|
||||
<SettingsStatusMessage>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@@ -826,7 +872,8 @@ export function ModelsSettings({
|
||||
</span>
|
||||
</SettingsStatusMessage>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
{creating && editorOpen ? renderPresetEditor() : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { Check, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -90,6 +90,7 @@ interface ModelPresetBadgeProps {
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
attentionRequest?: number;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -106,6 +107,7 @@ export function ModelPresetBadge({
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
attentionRequest = 0,
|
||||
fallbackModelName,
|
||||
isHero,
|
||||
onClick,
|
||||
@@ -272,11 +274,13 @@ export function ModelPresetBadge({
|
||||
|
||||
const pill = (
|
||||
<PresetPill
|
||||
key={needsSetup ? attentionRequest : undefined}
|
||||
label={displayLabel}
|
||||
modelDetail={displayModelDetail}
|
||||
provider={displayProvider}
|
||||
providerLabel={fallbackModelName ? null : providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
needsAttention={needsSetup && attentionRequest > 0}
|
||||
fallbackModelName={fallbackModelName}
|
||||
fallbackFromLabel={fallbackModelName ? label : null}
|
||||
isHero={isHero}
|
||||
@@ -493,6 +497,7 @@ function PresetPill({
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
needsAttention = false,
|
||||
fallbackModelName,
|
||||
fallbackFromLabel,
|
||||
isHero,
|
||||
@@ -504,6 +509,7 @@ function PresetPill({
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
needsAttention?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
fallbackFromLabel?: string | null;
|
||||
isHero: boolean;
|
||||
@@ -533,14 +539,16 @@ function PresetPill({
|
||||
return (
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-needs-setup={needsSetup ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackTitle || undefined}
|
||||
className={cn(
|
||||
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
"w-fit",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||
needsSetup && "composer-model-pill-setup",
|
||||
needsAttention && "composer-model-pill-setup-attention",
|
||||
offset !== undefined && "composer-model-pill-dock",
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
@@ -549,14 +557,15 @@ function PresetPill({
|
||||
zIndex: Math.round(scale * 100),
|
||||
}}
|
||||
>
|
||||
{!needsSetup ? (
|
||||
<PresetProviderIcon
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={inferredProvider}
|
||||
needsSetup={needsSetup}
|
||||
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||
testId={`composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||
isHero={isHero}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
@@ -564,7 +573,26 @@ function PresetPill({
|
||||
labelOverflows && "thread-composer-model-label-fade",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{needsSetup ? <SetupPromptLabel label={label} /> : label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupPromptLabel({ label }: { label: string }) {
|
||||
const separator = label.indexOf(" ");
|
||||
if (separator < 0) {
|
||||
return <span className="text-foreground/80">{label}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-testid="composer-model-setup-label">
|
||||
<span className="text-muted-foreground/90 transition-colors duration-150 group-hover:text-muted-foreground motion-reduce:transition-none">
|
||||
{label.slice(0, separator)}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-foreground/80 transition-colors duration-150 group-hover:text-foreground/90 motion-reduce:transition-none">
|
||||
{label.slice(separator + 1)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -574,20 +602,16 @@ function PresetProviderIcon({
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
needsSetup = false,
|
||||
testId,
|
||||
isHero,
|
||||
}: {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
needsSetup?: boolean;
|
||||
testId?: string;
|
||||
isHero: boolean;
|
||||
}) {
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const inferredProvider = provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
return (
|
||||
@@ -595,14 +619,11 @@ function PresetProviderIcon({
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center",
|
||||
needsSetup && "text-amber-800 dark:text-amber-200",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export function ThreadComposer({
|
||||
} | null>(null);
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [sendPending, setSendPending] = useState(false);
|
||||
const [modelSetupAttentionRequest, setModelSetupAttentionRequest] = useState(0);
|
||||
const interactionDisabled = !!disabled || sendPending;
|
||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
@@ -2008,7 +2009,9 @@ export function ThreadComposer({
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (modelNeedsSetup) {
|
||||
onModelBadgeClick?.();
|
||||
if (hasComposerContent) {
|
||||
setModelSetupAttentionRequest((request) => request + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!canSend) return;
|
||||
@@ -2112,11 +2115,11 @@ export function ThreadComposer({
|
||||
clear,
|
||||
clearComposerText,
|
||||
hasTouchPrimaryPointer,
|
||||
hasComposerContent,
|
||||
handleStop,
|
||||
isStreaming,
|
||||
maxTextBytes,
|
||||
modelNeedsSetup,
|
||||
onModelBadgeClick,
|
||||
onSend,
|
||||
onStop,
|
||||
onQuotedContextChange,
|
||||
@@ -2546,6 +2549,7 @@ export function ThreadComposer({
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
attentionRequest={modelSetupAttentionRequest}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
|
||||
@@ -961,7 +961,7 @@ export function ThreadShell({
|
||||
[activeModelPreset, modelName, settings],
|
||||
);
|
||||
const modelBadgeLabel = modelBadge.needsSetup
|
||||
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
|
||||
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
|
||||
: modelBadge.label;
|
||||
useEffect(() => {
|
||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||
|
||||
@@ -735,6 +735,63 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.composer-model-pill-setup {
|
||||
border-color: hsl(var(--border) / 0.72);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.62),
|
||||
0 1px 2px hsl(var(--foreground) / 0.035);
|
||||
transition-property: color, background-color, border-color, box-shadow, transform;
|
||||
transition-duration: 180ms;
|
||||
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.dark .composer-model-pill-setup {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.045),
|
||||
0 1px 2px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.thread-composer-model-badge:hover > .composer-model-pill-setup {
|
||||
border-color: hsl(var(--border));
|
||||
background-color: hsl(var(--accent) / 0.7);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.72),
|
||||
0 2px 5px hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
.dark .thread-composer-model-badge:hover > .composer-model-pill-setup {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.07),
|
||||
0 2px 5px rgb(0 0 0 / 0.16);
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-setup-attention {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
22% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
46% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
68% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-setup-attention {
|
||||
animation: composer-model-pill-setup-attention 280ms cubic-bezier(0.22, 0.8, 0.32, 1);
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
@@ -783,6 +840,14 @@
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.composer-model-pill-setup {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.composer-model-pill-setup-attention {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-dock {
|
||||
transition: none;
|
||||
|
||||
@@ -257,11 +257,14 @@ export function useSessions(): {
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
const optimistic = optimisticKeysRef.current.has(key);
|
||||
const result = await apiDeleteSession(client, key, options);
|
||||
if (!result.deleted) return result;
|
||||
if (result.blocked_by_automations || (!result.deleted && !optimistic)) return result;
|
||||
optimisticKeysRef.current.delete(key);
|
||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||
return result;
|
||||
// The gateway may have restarted and forgotten an unpersisted chat's
|
||||
// draft scope, but removing that optimistic session is still a deletion.
|
||||
return result.deleted ? result : { ...result, deleted: true };
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"presetName": "Preset name",
|
||||
"presetNameHelp": "Used in the interface and /model commands. Names must be unique.",
|
||||
"presetNameDuplicate": "A preset with this name already exists.",
|
||||
"presetNamePlaceholder": "Fast writing",
|
||||
"presetNamePlaceholder": "e.g. Fast writing",
|
||||
"advancedOptions": "Advanced options",
|
||||
"advancedSummary": "Context {{context}} · Max {{max}} tokens",
|
||||
"maxTokens": "Max output tokens",
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"removeQuotedContext": "Remove quoted context",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"chooseAI": "Choose your AI",
|
||||
"switchModel": "Switch model for this chat",
|
||||
"manageModels": "Manage models",
|
||||
"context": {
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"presetName": "Nombre del preajuste",
|
||||
"presetNameHelp": "Se usa en la interfaz y en /model. Los nombres deben ser únicos.",
|
||||
"presetNameDuplicate": "Ya existe un preajuste con este nombre.",
|
||||
"presetNamePlaceholder": "Escritura rápida",
|
||||
"presetNamePlaceholder": "p. ej., Escritura rápida",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
|
||||
"maxTokens": "Máx. tokens de salida",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"removeQuotedContext": "Quitar contexto citado",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"chooseAI": "Elige tu IA",
|
||||
"switchModel": "Cambiar el modelo de este chat",
|
||||
"manageModels": "Gestionar modelos",
|
||||
"context": {
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"presetName": "Nom du préréglage",
|
||||
"presetNameHelp": "Utilisé dans l’interface et avec /model. Les noms doivent être uniques.",
|
||||
"presetNameDuplicate": "Un préréglage portant ce nom existe déjà.",
|
||||
"presetNamePlaceholder": "Rédaction rapide",
|
||||
"presetNamePlaceholder": "p. ex. Rédaction rapide",
|
||||
"advancedOptions": "Options avancées",
|
||||
"advancedSummary": "Contexte {{context}} · Max. {{max}} tokens",
|
||||
"maxTokens": "Tokens de sortie max.",
|
||||
@@ -1181,6 +1181,7 @@
|
||||
"removeQuotedContext": "Supprimer le contexte cité",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"chooseAI": "Choisissez votre IA",
|
||||
"switchModel": "Changer le modèle de cette conversation",
|
||||
"manageModels": "Gérer les modèles",
|
||||
"context": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user