Merge remote-tracking branch 'origin/main' into codex/unified-extension-platform

This commit is contained in:
Xubin Ren 2026-07-27 14:37:18 +08:00
commit 3f29b10f0d
42 changed files with 231 additions and 485 deletions

View File

@ -186,9 +186,7 @@ Dream is configured under `agents.defaults.dream`:
"defaults": {
"dream": {
"intervalH": 2,
"modelOverride": null,
"maxBatchSize": 20,
"maxIterations": 10
"modelOverride": null
}
}
}
@ -200,15 +198,12 @@ Dream is configured under `agents.defaults.dream`:
| `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice

View File

@ -69,7 +69,7 @@ class ContextBuilder:
def build_system_prompt(
self,
skill_names: list[str] | None = None,
*,
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
@ -196,14 +196,11 @@ class ContextBuilder:
self,
history: list[dict[str, Any]],
current_message: str,
skill_names: list[str] | None = None,
*,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None,
include_memory_recent_history: bool = True,
@ -219,7 +216,6 @@ class ContextBuilder:
{
"role": "system",
"content": self.build_system_prompt(
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,

View File

@ -13,7 +13,7 @@ from dataclasses import dataclass, field
from enum import Enum, auto
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
from loguru import logger
@ -103,15 +103,7 @@ if TYPE_CHECKING:
)
from nanobot.cron.service import CronService
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
COMMAND = auto()
BUILD = auto()
RUN = auto()
SAVE = auto()
RESPOND = auto()
DONE = auto()
_T = TypeVar("_T")
class TurnKind(Enum):
@ -119,20 +111,10 @@ class TurnKind(Enum):
SYSTEM = auto()
@dataclass
class StateTraceEntry:
state: TurnState
started_at: float
duration_ms: float
event: str
error: str | None = None
@dataclass
class TurnContext:
msg: InboundMessage
session_key: str
state: TurnState
turn_id: str
runtime: LLMRuntime | None
kind: TurnKind
@ -146,7 +128,6 @@ class TurnContext:
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
final_content: str | None = None
tools_used: list[str] = field(default_factory=list)
all_messages: list[dict[str, Any]] = field(default_factory=list)
stop_reason: str = ""
had_injections: bool = False
@ -178,8 +159,6 @@ class TurnContext:
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list)
class AgentLoop:
"""
@ -244,19 +223,6 @@ class AgentLoop:
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
# Event-driven state transition table.
# Handlers return an event string; the driver looks up the next state here.
_TRANSITIONS: dict[tuple[TurnState, str], TurnState] = {
(TurnState.RESTORE, "ok"): TurnState.COMPACT,
(TurnState.COMPACT, "ok"): TurnState.COMMAND,
(TurnState.COMMAND, "dispatch"): TurnState.BUILD,
(TurnState.COMMAND, "shortcut"): TurnState.DONE,
(TurnState.BUILD, "ok"): TurnState.RUN,
(TurnState.RUN, "ok"): TurnState.SAVE,
(TurnState.SAVE, "ok"): TurnState.RESPOND,
(TurnState.RESPOND, "ok"): TurnState.DONE,
}
def __init__(
self,
bus: MessageBus,
@ -712,13 +678,8 @@ class AgentLoop:
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,
chat_id=str(
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
),
current_role="user",
sender_id=ctx.msg.sender_id,
session_summary=ctx.pending_summary,
session_metadata=ctx.session.metadata,
workspace=scope.project_path,
runtime_context_blocks=ctx.runtime_context_blocks,
include_memory_recent_history=not ctx.ephemeral,
@ -1379,7 +1340,6 @@ class AgentLoop:
msg=msg,
session=None,
session_key=key,
state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}",
runtime=runtime,
kind=kind,
@ -1449,65 +1409,47 @@ class AgentLoop:
ctx.on_stream = _tracked_stream
ctx.on_stream_end = _tracked_stream_end
while ctx.state is not TurnState.DONE:
handler_name = f"_state_{ctx.state.name.lower()}"
handler = getattr(self, handler_name, None)
if handler is None:
raise RuntimeError(f"Missing state handler for {ctx.state}")
t0 = time.perf_counter()
try:
event = await handler(ctx)
except Exception:
duration = (time.perf_counter() - t0) * 1000
ctx.trace.append(
StateTraceEntry(
state=ctx.state,
started_at=t0,
duration_ms=duration,
event="",
error="exception",
)
)
raise
duration = (time.perf_counter() - t0) * 1000
ctx.trace.append(
StateTraceEntry(
state=ctx.state,
started_at=t0,
duration_ms=duration,
event=event,
)
)
logger.debug(
"[turn {}] State {} took {:.1f}ms -> event {}",
ctx.turn_id,
ctx.state.name,
duration,
event,
)
next_state = self._TRANSITIONS.get((ctx.state, event))
if next_state is None:
raise RuntimeError(
f"[turn {ctx.turn_id}] No transition from {ctx.state} "
f"on event {event!r}"
)
ctx.state = next_state
logger.debug(
"[turn {}] Turn completed after {} states",
ctx.turn_id,
len(ctx.trace),
)
await self._run_turn_stage(ctx, "restore", self._restore_turn)
await self._run_turn_stage(ctx, "compact", self._compact_session)
if await self._run_turn_stage(ctx, "command", self._dispatch_command):
return ctx.outbound
await self._run_turn_stage(ctx, "build", self._build_turn)
await self._run_turn_stage(ctx, "run", self._run_turn)
await self._run_turn_stage(ctx, "save", self._persist_turn)
await self._run_turn_stage(ctx, "respond", self._prepare_outbound)
return ctx.outbound
async def _run_turn_stage(
self,
ctx: TurnContext,
name: str,
handler: Callable[[TurnContext], Awaitable[_T]],
) -> _T:
started_at = time.perf_counter()
try:
result = await handler(ctx)
except Exception:
duration_ms = (time.perf_counter() - started_at) * 1000
logger.debug(
"[turn {}] Stage {} failed after {:.1f}ms",
ctx.turn_id,
name,
duration_ms,
)
raise
duration_ms = (time.perf_counter() - started_at) * 1000
logger.debug(
"[turn {}] Stage {} completed in {:.1f}ms",
ctx.turn_id,
name,
duration_ms,
)
return result
def _assemble_outbound(
self,
msg: InboundMessage,
final_content: str,
all_msgs: list[dict[str, Any]],
stop_reason: str,
had_injections: bool,
streamed_content: bool,
@ -1538,7 +1480,7 @@ class AgentLoop:
metadata=meta,
)
async def _state_restore(self, ctx: TurnContext) -> TurnState:
async def _restore_turn(self, ctx: TurnContext) -> None:
"""Restore checkpoint / pending user turn; extract documents."""
msg = ctx.msg
@ -1571,8 +1513,6 @@ class AgentLoop:
if self._restore_pending_user_turn(ctx.session):
self.sessions.save(ctx.session)
return "ok"
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
if self._should_extract_document_text():
return extract_documents(content, media)
@ -1583,14 +1523,13 @@ class AgentLoop:
return True
return self.channels_config.extract_document_text
async def _state_compact(self, ctx: TurnContext) -> str:
async def _compact_session(self, ctx: TurnContext) -> None:
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
ctx.pending_summary = pending
return "ok"
async def _state_command(self, ctx: TurnContext) -> str:
async def _dispatch_command(self, ctx: TurnContext) -> bool:
if ctx.kind is TurnKind.SYSTEM:
return "dispatch"
return False
raw = ctx.msg.content.strip()
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
is_user_turn = (
@ -1626,10 +1565,10 @@ class AgentLoop:
)
self.sessions.save(ctx.session)
self._clear_pending_user_turn(ctx.session)
return "shortcut"
return "dispatch"
return True
return False
async def _state_build(self, ctx: TurnContext) -> str:
async def _build_turn(self, ctx: TurnContext) -> None:
runtime = ctx.runtime
if runtime is None:
runtime = self.runtime_for_session(ctx.session)
@ -1685,9 +1624,7 @@ class AgentLoop:
if ctx.on_retry_wait is None:
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
async def _run_turn(self, ctx: TurnContext) -> None:
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)
@ -1714,17 +1651,15 @@ class AgentLoop:
tools=ctx.tools,
request_context=ctx.request_context,
)
final_content, tools_used, all_msgs, stop_reason, had_injections = result
final_content, _, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content
ctx.tools_used = tools_used
ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason
ctx.had_injections = had_injections
if ctx.kind is TurnKind.USER:
await turn_continuation.maybe_continue_turn(ctx)
return "ok"
async def _state_save(self, ctx: TurnContext) -> str:
async def _persist_turn(self, ctx: TurnContext) -> None:
turn_continuation.prepare_save_boundary(ctx)
if (
@ -1765,12 +1700,11 @@ class AgentLoop:
self._clear_pending_user_turn(ctx.session)
self._clear_runtime_checkpoint(ctx.session)
self.sessions.save(ctx.session)
return "ok"
async def _state_respond(self, ctx: TurnContext) -> str:
async def _prepare_outbound(self, ctx: TurnContext) -> None:
if ctx.suppress_response:
ctx.outbound = None
return "ok"
return
if ctx.kind is TurnKind.SYSTEM:
ctx.outbound = ctx.delivery.background_response(
ctx.final_content,
@ -1778,11 +1712,10 @@ class AgentLoop:
streamed=ctx.streamed_content,
latency_ms=ctx.turn_latency_ms,
)
return "ok"
return
ctx.outbound = self._assemble_outbound(
ctx.msg,
ctx.final_content,
ctx.all_messages,
ctx.stop_reason,
ctx.had_injections,
ctx.streamed_content,
@ -1790,7 +1723,6 @@ class AgentLoop:
)
if ctx.ephemeral and ctx.outbound is not None:
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
return "ok"
def _sanitize_persisted_blocks(
self,

View File

@ -912,7 +912,7 @@ class Consolidator:
) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary")
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
@ -920,10 +920,7 @@ class Consolidator:
history=history,
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
sender_id=None,
session_summary=summary,
session_metadata=session.metadata,
session_key=session.key,
unified_session=self.unified_session,
)

View File

@ -1327,7 +1327,7 @@ class AgentRunner:
return payload, event, exc
return payload, event, None
if is_tool_error_result(tool_call.name, result):
if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,

View File

@ -39,12 +39,6 @@ def _validate_patch_path(path: str) -> str:
return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int:
if not text:
return 0

View File

@ -28,7 +28,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"Not used for action='list' or action='remove'."
),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
every_seconds=IntegerSchema(description="Interval in seconds (for recurring tasks)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
@ -138,8 +138,6 @@ class CronTool(Tool):
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
deliver: bool = True,
**kwargs: Any,
) -> str:
if action == "add":
if self._in_cron_context.get():

View File

@ -447,7 +447,6 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
default=False,
),
yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
@ -458,20 +457,17 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
nullable=True,
),
wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0,
maximum=MAX_WAIT_FOR_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,

View File

@ -226,12 +226,10 @@ def _builtin_skill_read_path(path: str) -> Path | None:
tool_parameters_schema(
path=StringSchema("The file path to read"),
offset=IntegerSchema(
1,
description="Line number to start reading from (1-indexed, default 1)",
minimum=1,
),
limit=IntegerSchema(
2000,
description="Maximum number of lines to read (default 2000)",
minimum=1,
),
@ -790,13 +788,11 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1,
nullable=True,
),
line_hint=IntegerSchema(
1,
description=(
"Optional exact 1-based target line copied from read_file. "
"The selected old_text match must cover this line."
@ -805,7 +801,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
nullable=True,
),
expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.",
minimum=1,
nullable=True,
@ -1036,7 +1031,6 @@ class EditFileTool(_FsTool):
path=StringSchema("The directory path to list"),
recursive=BooleanSchema(description="Recursively list all files (default false)"),
max_entries=IntegerSchema(
200,
description="Maximum entries to return (default 200)",
minimum=1,
),

View File

@ -1293,7 +1293,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
tools_removed = 0
for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(state, registry, name)
tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, name)
state._mcp_servers = next_servers
@ -1467,7 +1467,7 @@ async def _refresh_terminated_server(
return current_tool
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
_unregister_server_tools(state, registry, server_name)
_unregister_server_tools(registry, server_name)
await _close_server(state, server_name)
connected = await connect_mcp_servers({server_name: cfg}, registry)
@ -1499,7 +1499,7 @@ def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str)
return tool_name.startswith(_tool_prefix(server_name))
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
removed = 0
for tool_name in list(registry.tool_names):
tool = registry.get(tool_name)

View File

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from nanobot.runtime_context import RuntimeContextProvider
def is_tool_error_result(name: str, result: Any) -> bool:
def is_tool_error_result(result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error
@ -215,7 +215,7 @@ class ToolRegistry:
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if is_tool_error_result(name, result):
if is_tool_error_result(result):
return ToolResult.error(str(result) + hint)
return result
except Exception as e:

View File

@ -52,11 +52,10 @@ class StringSchema(Schema):
class IntegerSchema(Schema):
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds."""
"""Integer parameter with a description and optional bounds."""
def __init__(
self,
value: int = 0,
*,
description: str = "",
minimum: int | None = None,
@ -64,7 +63,6 @@ class IntegerSchema(Schema):
enum: tuple[int, ...] | list[int] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum
@ -92,7 +90,6 @@ class NumberSchema(Schema):
def __init__(
self,
value: float = 0.0,
*,
description: str = "",
minimum: float | None = None,
@ -100,7 +97,6 @@ class NumberSchema(Schema):
enum: tuple[float, ...] | list[float] | None = None,
nullable: bool = False,
) -> None:
self._value = value
self._description = description
self._minimum = minimum
self._maximum = maximum

View File

@ -108,7 +108,6 @@ class _PreparedCommand:
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."

View File

@ -271,13 +271,12 @@ def _normalize_volcengine_auth_level(value: Any) -> int | None:
@tool_parameters(
tool_parameters_schema(
query=StringSchema("Search query"),
count=IntegerSchema(1, description="Results (1-10)", minimum=1, maximum=10),
count=IntegerSchema(description="Results (1-10)", minimum=1, maximum=10),
timeRange=StringSchema(
"Optional time filter for providers that support it: "
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
),
authLevel=IntegerSchema(
0,
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
minimum=0,
maximum=1,
@ -939,7 +938,7 @@ class WebSearchTool(Tool):
"enum": ["markdown", "text"],
"default": "markdown",
},
maxChars=IntegerSchema(0, minimum=100),
maxChars=IntegerSchema(minimum=100),
required=["url"],
)
)

View File

@ -3,8 +3,6 @@
from __future__ import annotations
import pkgutil
from functools import cache
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
from loguru import logger
@ -19,22 +17,6 @@ if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
@cache
def _warn_legacy_channel_entry_points() -> None:
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
# migration window for installed legacy channel entry points.
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
if not names:
return
logger.warning(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
", ".join(names),
"nanobot.channels",
)
def _channel_package_names() -> list[str]:
import nanobot.channels as package
@ -49,7 +31,6 @@ def discover_plugins(
enabled_names: set[str] | None = None,
) -> dict[str, ChannelPlugin]:
"""Load dependency-free descriptors from self-contained channel packages."""
_warn_legacy_channel_entry_points()
plugins: dict[str, ChannelPlugin] = {}
for name in _channel_package_names():
if enabled_names is not None and name not in enabled_names:

View File

@ -816,7 +816,6 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
_warn_deprecated_config_keys(config_path)
if workspace:
loaded.agents.defaults.workspace = workspace
return loaded
@ -837,24 +836,6 @@ def _read_trigger_cli_message(message: str | None) -> str:
raise typer.Exit(1)
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file."""
import json
from nanobot.config.loader import get_config_path
path = config_path or get_config_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
console.print(
"[dim]Hint: `memoryWindow` in your config is no longer used "
"and can be safely removed.[/dim]"
)
def _load_inspection_config(
config: str | None = None,
workspace: str | None = None,
@ -874,7 +855,6 @@ def _load_inspection_config(
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
_warn_deprecated_config_keys(display_path)
if workspace:
loaded.agents.defaults.workspace = workspace
return display_path, loaded

View File

@ -7,7 +7,6 @@ from pathlib import Path
from typing import Any
import pydantic
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs
@ -200,23 +199,6 @@ def _env_replace(match: re.Match[str]) -> str:
def _migrate_config(data: dict) -> dict:
"""Migrate old config formats to current."""
agents = data.get("agents", {})
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
if isinstance(defaults, dict):
had_legacy_max_messages = (
"maxMessages" in defaults or "max_messages" in defaults
)
defaults.pop("maxMessages", None)
defaults.pop("max_messages", None)
if had_legacy_max_messages:
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
# final release that warns before the schema silently ignores the field.
logger.warning(
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
"replay max messages is now an internal safety cap. Remove it from "
"config. This compatibility warning will be removed in the next version."
)
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {})
exec_cfg = tools.get("exec", {})

View File

@ -64,9 +64,6 @@ class DreamConfig(Base):
default=None,
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
) # Override model for Dream sessions (pending implementation)
max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used
max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used
annotate_line_ages: bool = True # Deprecated: no longer used
def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present."""

View File

@ -5,7 +5,6 @@ import errno
import json
import os
import re
import shutil
from collections import OrderedDict
from contextlib import suppress
from copy import deepcopy
@ -477,6 +476,14 @@ class SessionManager:
except _SESSION_DATA_ERRORS:
return None
@classmethod
def _session_key_from_path(cls, path: Path) -> str | None:
"""Decode a session key only from a canonical collision-resistant filename."""
key = cls._decode_storage_key(path.stem)
if key is None or cls._storage_key(key) != path.stem:
return None
return key
def _get_session_path(self, key: str) -> Path:
"""Get the collision-resistant workspace path for a session."""
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
@ -489,61 +496,6 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/)."""
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
@staticmethod
def _stored_key_for_path(path: Path) -> str | None:
"""Read the stored session key from a JSONL metadata row, if present."""
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if not isinstance(data, dict):
raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata":
stored_key = data.get("key")
return stored_key if isinstance(stored_key, str) else None
return None
except _SESSION_DATA_ERRORS:
return None
return None
def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None:
"""Resolve a session path, falling back to legacy storage locations."""
path = self._get_session_path(key)
if path.exists():
return path
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
# compatibility window for reading and lazily migrating legacy session files.
fallback_paths = [
(self._get_legacy_lossy_path(key), "legacy lossy path"),
(self._get_legacy_session_path(key), "legacy path"),
]
for fallback_path, description in fallback_paths:
if not fallback_path.exists():
continue
stored_key = self._stored_key_for_path(fallback_path)
if stored_key and stored_key != key:
logger.info(
"Skipping session {} from {} because it belongs to {}",
key,
description,
stored_key,
)
continue
if not migrate:
return fallback_path
try:
shutil.move(str(fallback_path), str(path))
logger.info("Migrated session {} from {}", key, description)
except Exception:
logger.exception("Failed to migrate session {}", key)
return None
return path
return None
def get_or_create(self, key: str) -> Session:
"""
Get an existing session or create a new one.
@ -567,8 +519,8 @@ class SessionManager:
def _load(self, key: str) -> Session | None:
"""Load a session from disk."""
path = self._resolve_session_path(key, migrate=True)
if path is None:
path = self._get_session_path(key)
if not path.exists():
return None
try:
@ -847,8 +799,8 @@ class SessionManager:
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
``None`` when the session file does not exist or fails to parse.
"""
path = self._resolve_session_path(key)
if path is None:
path = self._get_session_path(key)
if not path.exists():
return None
try:
messages: list[dict[str, Any]] = []
@ -890,8 +842,8 @@ class SessionManager:
This is used by WebUI routes that need session-level metadata but not the
full conversation transcript.
"""
path = self._resolve_session_path(key)
if path is None:
path = self._get_session_path(key)
if not path.exists():
return None
try:
with open(path, encoding="utf-8") as f:
@ -935,8 +887,9 @@ class SessionManager:
sessions = []
for path in self.sessions_dir.glob("*.jsonl"):
decoded = self._decode_storage_key(path.stem)
fallback_key = decoded or path.stem.replace("_", ":", 1)
storage_key = self._session_key_from_path(path)
if storage_key is None:
continue
try:
# Read the metadata line and a small preview for session lists.
with open(path, encoding="utf-8") as f:
@ -946,7 +899,7 @@ class SessionManager:
if not isinstance(data, dict):
raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata":
key = data.get("key") or fallback_key
key = data.get("key") or storage_key
metadata = data.get("metadata", {})
title = _metadata_title(metadata)
preview = ""
@ -991,7 +944,7 @@ class SessionManager:
except FileNotFoundError:
continue
except _SESSION_DATA_ERRORS:
repaired = self._repair(fallback_key, path=path)
repaired = self._repair(storage_key, path=path)
if repaired is not None:
sessions.append(
{

View File

@ -54,7 +54,11 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
for row in existing_rows or []
if isinstance(row.get("file"), str)
}
paths = sorted(session_manager.sessions_dir.glob("*.jsonl"))
paths = sorted(
path
for path in session_manager.sessions_dir.glob("*.jsonl")
if SessionManager._session_key_from_path(path) is not None
)
rows: list[dict[str, Any]] = []
changed = existing_rows is None
@ -268,8 +272,9 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
storage_key = SessionManager._decode_storage_key(path.stem)
fallback_key = storage_key or path.stem.replace("_", ":", 1)
storage_key = SessionManager._session_key_from_path(path)
if storage_key is None:
return None
try:
with open(path, encoding="utf-8") as f:
first_line = f.readline().strip()
@ -320,7 +325,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
created_at_s = created_at_s or fallback_time
updated_at_s = updated_at_s or fallback_time
key = data.get("key") or fallback_key
key = data.get("key") or storage_key
activity_signature = _webui_activity_signature(key)
activity_updated_at = _webui_activity_updated_at(activity_signature)
return {
@ -340,7 +345,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
**activity_signature,
}
except Exception:
repaired = session_manager._repair(fallback_key)
repaired = session_manager._repair(storage_key)
if repaired is None:
return None
return _indexed_row_for_session(repaired, path)

View File

@ -353,6 +353,14 @@ class TestBuildSystemPrompt:
class TestBuildMessages:
def test_optional_arguments_are_keyword_only(self, tmp_path):
builder = _builder(tmp_path)
with pytest.raises(TypeError):
builder.build_system_prompt(["legacy-skill"])
with pytest.raises(TypeError):
builder.build_messages([], "hello", ["legacy-skill"])
def test_basic_empty_history(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages([], "hello")
@ -363,7 +371,7 @@ class TestBuildMessages:
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
messages = builder.build_messages([], "hello", channel="cli")
user_msg = str(messages[-1]["content"])
assert user_msg == "hello"

View File

@ -70,7 +70,6 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
history=[],
current_message="hello world",
channel="cli",
chat_id="direct",
runtime_context_blocks=[
RuntimeContextBlock(source="test", content="provider context"),
],
@ -322,7 +321,7 @@ def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
messages = builder.build_messages(
history=[], current_message="hi",
channel="telegram", chat_id="123",
channel="telegram",
)
system = messages[0]["content"]
assert "Format Hint" in system
@ -349,7 +348,6 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
history=[{"role": "assistant", "content": "previous result"}],
current_message="subagent result",
channel="cli",
chat_id="direct",
current_role="assistant",
)

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind, TurnState
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig
@ -27,7 +27,7 @@ def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) ->
@pytest.mark.asyncio
async def test_state_restore_extracts_documents_by_default(
async def test_restore_turn_extracts_documents_by_default(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -52,14 +52,13 @@ async def test_state_restore_extracts_documents_by_default(
ctx = TurnContext(
msg=msg,
session_key="cli:c",
state=TurnState.RESTORE,
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
assert await loop._state_restore(ctx) == "ok"
await loop._restore_turn(ctx)
assert calls == [("summarize", [str(doc_path)])]
assert "Quarterly revenue" in ctx.msg.content
@ -67,7 +66,7 @@ async def test_state_restore_extracts_documents_by_default(
@pytest.mark.asyncio
async def test_state_restore_references_documents_when_extraction_disabled(
async def test_restore_turn_references_documents_when_extraction_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -90,14 +89,13 @@ async def test_state_restore_references_documents_when_extraction_disabled(
ctx = TurnContext(
msg=msg,
session_key="cli:c",
state=TurnState.RESTORE,
turn_id="turn-1",
runtime=loop.llm_runtime(),
kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
)
assert await loop._state_restore(ctx) == "ok"
await loop._restore_turn(ctx)
assert "Quarterly revenue" not in ctx.msg.content
assert f"[Attachment: {doc_path}]" in ctx.msg.content

View File

@ -414,13 +414,13 @@ class TestEphemeralDirect:
captured = {}
original_save = loop._state_save
original_save = loop._persist_turn
async def patched_save(ctx):
captured["ephemeral"] = ctx.ephemeral
return await original_save(ctx)
with patch.object(loop, "_state_save", side_effect=patched_save):
with patch.object(loop, "_persist_turn", side_effect=patched_save):
await loop.process_direct(
"test", session_key="dream:check", ephemeral=True,
)
@ -435,13 +435,13 @@ class TestEphemeralDirect:
captured = {}
original_save = loop._state_save
original_save = loop._persist_turn
async def patched_save(ctx):
captured["ephemeral"] = ctx.ephemeral
return await original_save(ctx)
with patch.object(loop, "_state_save", side_effect=patched_save):
with patch.object(loop, "_persist_turn", side_effect=patched_save):
await loop.process_direct("test", session_key="cli:normal")
assert captured.get("ephemeral") is False

View File

@ -7,7 +7,7 @@ import pytest
from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop, TurnState
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
@ -451,7 +451,6 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
[],
user_text,
channel="cli",
chat_id="direct",
)
assert "_meta" not in messages[-1]
@ -476,7 +475,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
user_text,
media=[str(image)],
channel="cli",
chat_id="direct",
)
loop._save_turn(session, messages, skip=1)
@ -1101,7 +1099,7 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
@pytest.mark.asyncio
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
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]
@ -1135,12 +1133,11 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path:
assert result is not None
assert result.chat_id == "thread-777"
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
@pytest.mark.asyncio
async def test_process_message_uses_explicit_session_metadata_for_goal_context(
async def test_process_message_uses_explicit_session_for_goal_context(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
@ -1185,10 +1182,10 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
assert result is not None
assert result.content == "ok"
kwargs = loop.context.build_messages.call_args.kwargs
assert kwargs["chat_id"] == "chat-with-goal"
assert kwargs["session_metadata"] is system_session.metadata
assert GOAL_STATE_KEY not in kwargs["session_metadata"]
kwargs = loop._run_agent_loop.call_args.kwargs
assert kwargs["session"] is system_session
assert kwargs["session_key"] == "system"
assert GOAL_STATE_KEY not in kwargs["session"].metadata
@pytest.mark.asyncio
@ -1570,27 +1567,26 @@ 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_state_machine(tmp_path: Path) -> None:
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[TurnState] = []
visited: list[str] = []
for state in (
TurnState.RESTORE,
TurnState.COMPACT,
TurnState.COMMAND,
TurnState.BUILD,
TurnState.RUN,
TurnState.SAVE,
TurnState.RESPOND,
for name in (
"_restore_turn",
"_compact_session",
"_dispatch_command",
"_build_turn",
"_run_turn",
"_persist_turn",
"_prepare_outbound",
):
name = f"_state_{state.name.lower()}"
original = getattr(loop, name)
async def record(ctx, *, _original=original, _state=state):
visited.append(_state)
async def record(ctx, *, _original=original, _name=name):
visited.append(_name)
return await _original(ctx)
setattr(loop, name, record)
@ -1606,25 +1602,33 @@ async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path:
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
logs: list[str] = []
sink_id = logger.add(logs.append, level="DEBUG", format="{message}")
try:
await loop._process_message(
InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
)
)
finally:
logger.remove(sink_id)
assert visited == [
TurnState.RESTORE,
TurnState.COMPACT,
TurnState.COMMAND,
TurnState.BUILD,
TurnState.RUN,
TurnState.SAVE,
TurnState.RESPOND,
"_restore_turn",
"_compact_session",
"_dispatch_command",
"_build_turn",
"_run_turn",
"_persist_turn",
"_prepare_outbound",
]
logged = "".join(logs)
for stage in ("restore", "compact", "command", "build", "run", "save", "respond"):
assert f"Stage {stage} completed in" in logged
@pytest.mark.asyncio
@ -1689,7 +1693,6 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
current_message="subagent result",
current_role="user",
channel="cli",
chat_id="merge",
)
non_system = [m for m in projected if m.get("role") != "system"]

View File

@ -225,7 +225,7 @@ async def test_process_message_captures_original_text_before_restore(
seen.append((ctx.original_user_text, ctx.runtime))
raise RuntimeError("captured before restore")
loop._state_restore = stop_after_capture # type: ignore[method-assign]
loop._restore_turn = stop_after_capture # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="captured before restore"):
await loop._process_message(

View File

@ -135,7 +135,7 @@ async def test_tool_fails_after_retry_exhausted():
assert "failed after retry" in output
assert "ClosedResourceError" in output
assert is_tool_error_result(wrapper.name, output)
assert is_tool_error_result(output)
assert session.call_tool.call_count == 2

View File

@ -65,7 +65,7 @@ def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:legacy:lossy"
lossy_path = sm._get_legacy_lossy_path(key)
@ -73,49 +73,23 @@ def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
session = sm._load(key)
assert session is not None
assert session.metadata == {"source": "test"}
assert session.messages[0]["content"] == "loaded from lossy"
assert session is None
assert lossy_path.exists()
assert not sm._get_session_path(key).exists()
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
def test_load_ignores_legacy_global_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:migrate:lossy"
key = "telegram:legacy:global"
new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "migrate me")
legacy_path = sm._get_legacy_session_path(key)
_write_session_file(legacy_path, key, "loaded from global")
session = sm._load(key)
assert session is not None
assert session.messages[0]["content"] == "migrate me"
assert new_path.exists()
assert not lossy_path.exists()
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first_key = "telegram:a_b"
second_key = "telegram:a:b"
lossy_path = sm._get_legacy_lossy_path(first_key)
assert lossy_path == sm._get_legacy_lossy_path(second_key)
_write_session_file(lossy_path, first_key, "belongs to first")
loaded_second = sm._load(second_key)
assert loaded_second is None
assert lossy_path.exists()
assert not sm._get_session_path(second_key).exists()
loaded_first = sm._load(first_key)
assert loaded_first is not None
assert loaded_first.messages[0]["content"] == "belongs to first"
assert sm._get_session_path(first_key).exists()
assert not lossy_path.exists()
assert session is None
assert legacy_path.exists()
assert not new_path.exists()
def test_safe_key_is_lossy() -> None:

View File

@ -140,5 +140,5 @@ async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
result = await tool.execute(value="1")
assert is_tool_error_result("api_plugin", result) is True
assert is_tool_error_result(result) is True
assert str(result) == "Error: plugin failed"

View File

@ -78,7 +78,7 @@ async def test_run_inline_returns_structured_error(tmp_path):
)
assert result == "subagent failed"
assert is_tool_error_result("spawn", result)
assert is_tool_error_result(result)
assert manager._running_tasks == {}
assert manager._session_tasks == {}

View File

@ -788,35 +788,6 @@ def test_discover_plugins_skips_names_outside_enabled_set():
assert loaded == []
def test_discover_plugins_warns_once_for_legacy_entry_points():
from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins
legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")]
_warn_legacy_channel_entry_points.cache_clear()
try:
with (
patch(
"nanobot.channels.registry.entry_points",
return_value=legacy_entry_points,
) as metadata_entry_points,
patch("nanobot.channels.registry._channel_package_names", return_value=[]),
patch("nanobot.channels.registry.logger.warning") as warning,
):
discover_plugins()
discover_plugins()
finally:
_warn_legacy_channel_entry_points.cache_clear()
metadata_entry_points.assert_called_once_with(group="nanobot.channels")
warning.assert_called_once_with(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
"a-old, z-old",
"nanobot.channels",
)
def test_channel_manifest_rejects_invalid_dependency_metadata():
with pytest.raises(TypeError, match="tuple of requirements"):
ChannelPlugin(

View File

@ -1744,17 +1744,6 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
assert passed_config.workspace_path == workspace_path
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"agents": {"defaults": {"memoryWindow": 42}}}))
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
assert result.exit_code == 0
assert "memoryWindow" in result.stdout
assert "no longer used" in result.stdout
def test_heartbeat_retains_recent_messages_by_default():
config = Config()

View File

@ -96,22 +96,17 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None:
def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
encoding="utf-8",
)
with patch("nanobot.config.loader.logger.warning") as warning:
config = load_config(config_path)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234
assert not hasattr(config.agents.defaults, "max_messages")
warning.assert_called_once()
message = warning.call_args.args[0]
assert "legacy and ignored" in message
assert "next version" in message
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
@ -121,8 +116,7 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
encoding="utf-8",
)
with patch("nanobot.config.loader.logger.warning"):
config = load_config(config_path)
config = load_config(config_path)
save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))

View File

@ -52,3 +52,22 @@ def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> Non
assert cfg.model_override == "openrouter/sonnet"
assert dumped["modelOverride"] == "openrouter/sonnet"
assert "model" not in dumped
def test_dream_config_ignores_retired_noop_fields() -> None:
cfg = DreamConfig.model_validate(
{
"maxBatchSize": 99,
"maxIterations": 99,
"annotateLineAges": False,
}
)
dumped = cfg.model_dump(by_alias=True)
assert not hasattr(cfg, "max_batch_size")
assert not hasattr(cfg, "max_iterations")
assert not hasattr(cfg, "annotate_line_ages")
assert "maxBatchSize" not in dumped
assert "maxIterations" not in dumped
assert "annotateLineAges" not in dumped

View File

@ -1,4 +1,5 @@
"""Regression tests for legacy-stem session handling."""
"""Tests for retired legacy session storage paths."""
import json
from datetime import datetime
from pathlib import Path
@ -6,15 +7,11 @@ from pathlib import Path
from nanobot.session.manager import SessionManager
def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: tmp_path / "legacy_sessions",
)
def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
manager = SessionManager(tmp_path / "workspace")
# Simulate a legacy lossy-path filename (telegram_12345.jsonl) with a corrupt
# first line that triggers the repair branch in list_sessions.
# A legacy lossy-path filename must not be treated as current session storage,
# even when the file contains otherwise recoverable records.
legacy_stem = "telegram_12345"
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
corrupt_path.parent.mkdir(parents=True, exist_ok=True)
@ -24,7 +21,6 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
"created_at": datetime(2025, 1, 1).isoformat(),
"updated_at": datetime(2025, 1, 1).isoformat(),
})
# Corrupt line followed by valid message
corrupt_path.write_text(
metadata + "\n{INVALID JSON LINE\n"
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
@ -33,14 +29,11 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
sessions = manager.list_sessions()
# BUG: repair fails because _repair re-encodes the fallback_key via
# _get_session_path, producing a base64 stem that doesn't match the
# actual legacy filename. The session is silently dropped.
assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}"
assert sessions[0]["key"] == "telegram:12345"
assert sessions == []
assert corrupt_path.exists()
def test_read_session_methods_fall_back_to_legacy_lossy_stem(
def test_read_session_methods_ignore_legacy_lossy_stem(
tmp_path: Path,
monkeypatch,
) -> None:
@ -69,8 +62,5 @@ def test_read_session_methods_fall_back_to_legacy_lossy_stem(
metadata_result = manager.read_session_metadata(key)
file_result = manager.read_session_file(key)
assert metadata_result is not None
assert metadata_result["metadata"] == metadata["metadata"]
assert file_result is not None
assert file_result["metadata"] == metadata["metadata"]
assert file_result["messages"] == []
assert metadata_result is None
assert file_result is None

View File

@ -380,7 +380,7 @@ def test_write_stdin_reports_missing_session(tmp_path):
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
assert is_tool_error_result("write_stdin", result)
assert is_tool_error_result(result)
def test_list_exec_sessions_reports_running_commands(tmp_path):

View File

@ -449,7 +449,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
result = await wrapper.execute()
assert result == "Error: server-side MCP failure"
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio
@ -462,7 +462,7 @@ async def test_execute_contains_malformed_success_result() -> None:
result = await wrapper.execute()
assert result == "(MCP tool returned malformed content: TypeError)"
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio
@ -476,7 +476,7 @@ async def test_registry_adds_retry_hint_to_malformed_mcp_result() -> None:
result = await registry.execute(wrapper.name, {})
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
assert "MCP tool returned malformed content" in result
assert "Analyze the error above and try a different approach" in result
@ -494,7 +494,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
result = await wrapper.execute()
assert result == "Error: generated report successfully"
assert not is_tool_error_result(wrapper.name, result)
assert not is_tool_error_result(result)
# Smallest valid 1x1 PNG, base64 without the data: prefix.
@ -562,7 +562,7 @@ async def test_execute_returns_timeout_message() -> None:
result = await wrapper.execute()
assert result == "(MCP tool call timed out after 0.01s)"
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio
@ -575,7 +575,7 @@ async def test_execute_handles_server_cancelled_error() -> None:
result = await wrapper.execute()
assert result == "(MCP tool call was cancelled)"
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio
@ -607,7 +607,7 @@ async def test_execute_handles_generic_exception() -> None:
result = await wrapper.execute()
assert result == "(MCP tool call failed: RuntimeError)"
assert is_tool_error_result(wrapper.name, result)
assert is_tool_error_result(result)
def _make_tool_def(name: str) -> SimpleNamespace:
@ -1631,7 +1631,7 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
assert wrapper._reconnect is not None
assert other_wrapper._reconnect is None
removed = mcp_mod._unregister_server_tools(SimpleNamespace(), registry, server_name)
removed = mcp_mod._unregister_server_tools(registry, server_name)
assert removed == 1
assert wrapper.name not in registry.tool_names

View File

@ -60,7 +60,7 @@ class SampleTool(Tool):
@tool_parameters(
tool_parameters_schema(
query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10),
count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"],
)
)
@ -81,12 +81,12 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
root = tool_parameters_schema(
query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10),
count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"],
)
obj = ObjectSchema(
query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10),
count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"],
)
params = {"query": "h", "count": 2}
@ -110,14 +110,14 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
expected = _Mini().validate_params(params)
assert Schema.validate_json_schema_value(params, root, "") == expected
assert obj.validate_value(params, "") == expected
assert IntegerSchema(0, minimum=1).validate_value(0, "n") == ["n must be >= 1"]
assert IntegerSchema(minimum=1).validate_value(0, "n") == ["n must be >= 1"]
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
built = tool_parameters_schema(
query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10),
count=IntegerSchema(minimum=1, maximum=10),
mode=StringSchema("", enum=["fast", "full"]),
meta=ObjectSchema(
tag=StringSchema(""),

View File

@ -272,7 +272,7 @@ async def test_serper_search_http_error(monkeypatch):
tool = _tool(provider="serper", api_key="bad-serper-key")
result = await tool.execute(query="serper")
assert "Error: Serper search failed (403)" in result
assert is_tool_error_result(tool.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio
@ -284,7 +284,7 @@ async def test_serper_search_rate_limited(monkeypatch):
tool = _tool(provider="serper", api_key="serper-key")
result = await tool.execute(query="serper")
assert "Serper search rate limited" in result
assert is_tool_error_result(tool.name, result)
assert is_tool_error_result(result)
@pytest.mark.asyncio

View File

@ -98,6 +98,20 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
assert list_webui_sessions(manager) == []
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
legacy_path.write_text(
'{"_type":"metadata","key":"websocket:legacy",'
'"created_at":"2025-01-01T00:00:00",'
'"updated_at":"2025-01-01T00:00:00","metadata":{}}\n',
encoding="utf-8",
)
assert list_webui_sessions(manager) == []
assert legacy_path.exists()
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:cron-preview")

View File

@ -115,9 +115,6 @@ function baseSettingsPayload() {
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},

View File

@ -259,9 +259,6 @@ function modelSettings(model: string, provider: string): SettingsPayload {
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},