Compare commits

..
Author SHA1 Message Date
chengyongruandGitHub d81aa5a4ab refactor(agent): let runner own context compaction (#5568)
* refactor(agent): consolidate accepted history under pressure

* fix(agent): align provider and session compaction

* refactor(agent): simplify runner context compaction

* refactor(agent): remove background token consolidation

* fix(agent): keep injected transcript messages distinct

* refactor(agent): unify native compaction summaries

* fix(agent): preserve native compaction boundary

* fix(agent): unify context compaction paths

* fix(agent): preserve exact compaction request boundaries
2026-09-02 18:05:54 +08:00
Xubin Ren da96c5c6eb fix(webui): activate existing model presets safely 2026-09-02 16:25:07 +08:00
Xubin Ren 7ba4bc02f0 fix(webui): make new preset row clickable 2026-09-02 16:25:07 +08:00
Xubin Ren 6b385a1da6 fix(webui): balance preset editor spacing 2026-09-02 16:25:07 +08:00
Xubin Ren 56699d4888 fix(webui): make preset names immediately saveable 2026-09-02 16:25:07 +08:00
Xubin Ren 0274c11bfb fix(webui): keep schema defaults out of model presets 2026-09-02 16:25:07 +08:00
Xubin Ren 94d62f86a0 fix(webui): nudge model setup on blocked submit 2026-09-02 16:25:07 +08:00
Xubin Ren 6b07c91aa2 fix(webui): migrate brand logos to default on 2026-09-02 16:25:07 +08:00
Xubin Ren a3e043cf6f style(webui): show third-party logos by default 2026-09-02 16:25:07 +08:00
Xubin Ren 05100b70ad refactor(webui): open models directly for first-run setup 2026-09-02 16:25:07 +08:00
Xubin Ren 1c1f746903 style(webui): refine first-run model prompt 2026-09-02 16:25:07 +08:00
Xubin Ren 138bf3c0b8 fix(webui): simplify unconfigured model badge 2026-09-02 16:25:07 +08:00
Xubin Ren 9a4781982e fix(webui): route first-run model setup choices 2026-09-02 16:25:07 +08:00
Xubin Ren b47f0980f1 feat(webui): add first-run AI setup flow 2026-09-02 16:25:07 +08:00
042f96f6ba fix(webui): delete unpersisted pane sessions (#5624)
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
2026-09-02 13:05:36 +08:00
yu-xin-candchengyongru 9ecdc4533f fix(agent): release completed task groups 2026-09-01 17:42:41 +08:00
dda67286f9 fix(dream): stop duplicating SOUL/USER/MEMORY into the Dream prompt (#5622)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
2026-09-01 17:17:35 +08:00
chengyongruandchengyongru eebe3ca292 fix(tui): preserve input typed after submit 2026-09-01 16:21:18 +08:00
Xubin Ren 22cbfc2fb5 fix(websocket): preserve invalid listener detection 2026-09-01 11:14:34 +08:00
Krislu1221andXubin Ren bd9b74e2c7 fix(websocket): stop treating SO_ACCEPTCONN as portable in listener health check
`_listener_is_serving()` probes `SO_ACCEPTCONN` to decide whether a bound
socket is still accepting connections. That option is not portable: macOS
and the BSDs raise `OSError` with `ENOPROTOOPT` ("Protocol not available")
for it even on a perfectly healthy listening socket.

The surrounding `except OSError: return False` swallows that error, so the
listener is reported as degraded on every check. `_serve_forever()` then
raises `_ListenerUnavailableError("WebSocket listener did not enter a
serving state")`, and because `_is_recoverable_listener_error()` classifies
that exception as recoverable, the channel retries forever (4s -> 8s -> 16s
-> 30s backoff) and never becomes ready. On macOS this leaves the gateway
permanently at `ready: false` with the WebUI unreachable, since the
WebSocket channel is enabled by default.

Reproduced on macOS 26.5.1 / Python 3.14.3:

    >>> s = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen(1)
    >>> s.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN)
    OSError: [Errno 42] Protocol not available

Extract `_socket_is_accepting()` and treat `ENOPROTOOPT` / `EOPNOTSUPP` /
`EINVAL` as "this platform cannot answer the question", falling back to the
file-descriptor liveness check that already guarded the expression. Other
`OSError`s still propagate, and a closed socket is still rejected via the
`fileno() < 0` short circuit, so Linux behaviour is unchanged.
2026-09-01 11:14:34 +08:00
chengyongruandchengyongru 3c25f826ea fix(telegram): detect unsupported rich stream edits 2026-08-31 18:39:35 +08:00
Nolanandchengyongru 195e4c281d fix(telegram): preserve final-edit retry contract in rich stream upgrade
Address review feedback on the rich edit error classification:

- Transport, rate-limit, and unexpected errors now propagate instead of
  returning False. Returning False made send_delta fall through to an
  immediate legacy edit_message_text, which under connection-pool
  exhaustion doubled demand and discarded the buffered retry state that
  ChannelManager relies on.
- 'Message is not modified' is treated as success: when the rich edit is
  applied server-side but its response times out, the retry inside
  _call_with_retry reports the edit as already applied, and the previous
  BadRequest branch would have let the legacy edit overwrite the
  successful rich result.
- False is now returned only for capability errors (pre-10.1 servers,
  which still trip the rich latch) and content-shaped rejections, where
  the legacy HTML path is the intended fallback.

Adds regression coverage for a direct NetworkError (propagates, buffer
kept for manager retry) and TimedOut followed by Message-is-not-modified
(treated as success, no legacy overwrite).
2026-08-31 18:39:35 +08:00
Nolanandchengyongru 37663ac947 fix(telegram): upgrade streaming preview to rich in place at stream end
The rich branch in send_delta(stream_end=True) was unreachable: it was
guarded by 'not buf.message_id' after an earlier return had already
ensured buf.message_id is set, so sendRichMessage never fired with
streaming enabled and the final message always went through the legacy
HTML editMessageText path.

Bot API 10.1 added a rich_message parameter to editMessageText, which
upgrades an existing message to rich in place. Use it at stream end via
do_api_request so the streaming preview keeps its identity — no
delete-and-resend, so none of the flickering or dropped line breaks
that made rich-at-stream-end fail in #4470.

Capability errors (server older than 10.1) trip the existing
_rich_send_disabled latch and fall back to the legacy HTML path, which
is unchanged.

Fixes #5516
2026-08-31 18:39:35 +08:00
chengyongruandGitHub e111b83af6 refactor(agent): unify runner request fitting (#5612)
* refactor(agent): unify runner request fitting

* fix(agent): fit every runner model request

* fix(agent): count resumed state during request fitting

* refactor(agent): consolidate request fitting state
2026-08-31 18:08:21 +08:00
chengyongruandchengyongru 6d6d58d329 style(tui): simplify runtime header controls 2026-08-31 17:03:24 +08:00
chengyongruandchengyongru e69159cdae style(tui): replace header dots with spacing 2026-08-31 17:03:24 +08:00
feb33e1f99 docs(tools): clarify edit_file selector exclusivity (#5598)
* docs(tools): clarify edit selector exclusivity

* docs(tools): streamline edit_file guidance

---------

Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
2026-08-31 14:28:41 +08:00
chengyongruandGitHub bb34b58f47 refactor(agent): make memory summaries cumulative (#5610)
* refactor(agent): make memory summaries cumulative

Treat the latest session summary as a replacement checkpoint, preserve it through bounded raw fallbacks, and reserve history.jsonl for Dream ingestion.

* fix(agent): preserve cumulative checkpoint context

* fix(agent): preserve memory archive prompt cache

* refactor(agent): state archive prompt positively

* refactor(agent): remove checkpoint version migration

* refactor(agent): summarize full archive context

* test(agent): align cumulative archive prompt assertion

* refactor(agent): clarify memory checkpoint contract
2026-08-31 13:37:20 +08:00
chengyongruandGitHub 6cd7063682 refactor(agent): defer transcript assembly to runner (#5608)
* refactor(agent): defer transcript assembly to runner

Keep persisted history and the fresh turn as explicit inputs until the Runner assembles the provider transcript. Preserve ContextBuilder and direct AgentRunner compatibility while making the save boundary structural.

Refs NAN-81.

* fix(providers): preserve mixed adjacent user content
2026-08-31 00:06:15 +08:00
Xubin Ren d019658501 fix(dingtalk): stop late inbound task creation 2026-08-30 17:56:34 +08:00
yu-xin-candXubin Ren e8385d9257 fix(dingtalk): drain inbound background tasks 2026-08-30 17:56:34 +08:00
Xubin Ren 5c71ef6e49 test(email): assert header-first fetch contract 2026-08-30 17:38:44 +08:00
Till AdamandXubin Ren f573ecfe56 perf(email): fetch headers before body, use UID SEARCH to skip re-fetch
The IMAP poll loop previously downloaded the entire message body for
every UNSEEN message before running any filter (self-sent, SPF/DKIM,
allow-list), and only learned the UID by parsing it back out of that
fetch response. Rejected messages stay unseen and get re-fetched in
full on every subsequent poll.

Switch to UID SEARCH (UIDs come back directly, no per-message fetch
needed to learn them) so already-processed UIDs are skipped before any
network fetch, then fetch headers only to evaluate every filter — the
full body is downloaded only for messages that pass every check and
are actually delivered. No behavior change: filter outcomes and
\Seen semantics are unchanged.
2026-08-30 17:38:44 +08:00
Xubin Ren 1ac1b35c84 fix(cron): sanitize migrated legacy metadata 2026-08-30 17:24:05 +08:00
Oxygen56andXubin Ren 679a07460e fix(cron): sanitize persisted origin metadata 2026-08-30 17:24:05 +08:00
Oxygen56andXubin Ren 919e3d341e fix(agent): add retry hint to tool exceptions 2026-08-30 17:03:51 +08:00
yu-xin-candXubin Ren 2c55934198 fix(agent): bound session message rate-limit state 2026-08-30 16:46:36 +08:00
Xubin Ren 5afdffff51 fix(agent): settle reasoning close before cancellation 2026-08-30 16:32:08 +08:00
KDBandXubin Ren bfe041def7 fix(agent): close reasoning stream on cancellation 2026-08-30 16:32:08 +08:00
Kail Tianandchengyongru 1c1b13a3a9 fix(tui): preserve cursor position on Windows exit 2026-08-30 12:17:40 +08:00
Xubin Ren 2c87143f77 fix(cli): preserve gateway log stream boundaries 2026-08-29 23:03:23 +08:00
Xubin Ren d7df2726de fix(cli): stream gateway logs in WebUI launcher 2026-08-29 23:03:23 +08:00
115 changed files with 5355 additions and 3105 deletions
+4 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "",
+15 -7
View File
@@ -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)
+7 -11
View File
@@ -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
+16 -3
View File
@@ -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,
+24 -2
View File
@@ -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)."""
+54 -39
View File
@@ -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:
+71 -19
View File
@@ -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
+23 -4
View File
@@ -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
-5
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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)
+38
View File
@@ -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:
+48 -1
View File
@@ -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
+22 -3
View File
@@ -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",
+12 -1
View File
@@ -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:
+3 -3
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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,
*,
+3 -7
View File
@@ -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
+34 -19
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`.
+9 -2
View File
@@ -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)
+76 -6
View File
@@ -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] = []
-1
View File
@@ -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(),
+4
View File
@@ -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
+4 -1
View File
@@ -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 -1
View File
@@ -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,
+3 -34
View File
@@ -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
View File
@@ -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"]
+26 -9
View File
@@ -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(
-168
View File
@@ -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
View File
@@ -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],
)
+7 -9
View File
@@ -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)
+9 -5
View File
@@ -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) "
+102 -139
View File
@@ -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(
+14 -20
View File
@@ -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",
+14 -13
View File
@@ -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")
+101 -52
View File
@@ -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",
[
-2
View File
@@ -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
+3 -2
View File
@@ -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",
-48
View File
@@ -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(
+5 -1
View File
@@ -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:
+48 -14
View File
@@ -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
+24
View File
@@ -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
File diff suppressed because it is too large Load Diff
+61 -30
View File
@@ -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
+93
View File
@@ -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
+36 -170
View File
@@ -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
+2 -2
View File
@@ -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)
-225
View File
@@ -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)
+59
View File
@@ -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):
+1 -111
View File
@@ -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
# ---------------------------------------------------------------------------
+8 -4
View File
@@ -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
+2 -23
View File
@@ -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(
+9 -2
View File
@@ -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
+9
View File
@@ -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
+40 -1
View File
@@ -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 = [
+29 -5
View File
@@ -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"]
+39
View File
@@ -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):
+3 -6
View File
@@ -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
+5 -6
View File
@@ -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
+37
View File
@@ -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."""
+4 -1
View File
@@ -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"
+60
View File
@@ -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,
+6 -2
View File
@@ -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
+79 -5
View 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"
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]) })
+13
View File
@@ -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 {}
+2 -5
View File
@@ -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()
}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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) {
+65
View File
@@ -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;
+5 -2
View File
@@ -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],
);
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -423,7 +423,7 @@
"presetName": "Nom du préréglage",
"presetNameHelp": "Utilisé dans linterface 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