mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-13 07:39:15 +03:00
fix(session): preserve history across storage relocation
Co-authored-by: lmzopq <1646888+lmzopq@users.noreply.github.com>
This commit is contained in:
+18
-5
@@ -25,14 +25,27 @@ def _isolate_nanobot_log_activation() -> Iterator[None]:
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||||
"""Redirect session storage away from the real ~/.nanobot/sessions.
|
"""Redirect session storage away from the real active config data directory.
|
||||||
|
|
||||||
Session storage lives under get_legacy_sessions_dir() (outside the workspace,
|
Session storage lives under the active runtime data root (outside the workspace,
|
||||||
per ADR-0001), so without redirection tests would write into the real home.
|
per ADR-0001), so without redirection tests would write into the real home.
|
||||||
"""
|
"""
|
||||||
root = tmp_path / "sessions-root"
|
runtime_root = tmp_path.parent / f"{tmp_path.name}-runtime-root"
|
||||||
monkeypatch.setattr("nanobot.session.manager.get_legacy_sessions_dir", lambda: root)
|
legacy_root = tmp_path.parent / f"{tmp_path.name}-legacy-sessions-root"
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_legacy_sessions_dir", lambda: root)
|
|
||||||
|
def runtime_subdir(name: str) -> Path:
|
||||||
|
path = runtime_root / name
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.session.manager.get_runtime_subdir",
|
||||||
|
runtime_subdir,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.session.manager.get_legacy_sessions_dir",
|
||||||
|
lambda: legacy_root,
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ Defaults:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Config | `~/.nanobot/config.json` |
|
| Config | `~/.nanobot/config.json` |
|
||||||
| Workspace | `~/.nanobot/workspace/` |
|
| Workspace | `~/.nanobot/workspace/` |
|
||||||
| Sessions | `~/.nanobot/sessions/<workspace-hash>/*.jsonl` |
|
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
|
||||||
| Memory | `<workspace>/memory/` |
|
| Memory | `<workspace>/memory/` |
|
||||||
| Cron store | `<workspace>/cron/jobs.json` |
|
| Cron store | `<workspace>/cron/jobs.json` |
|
||||||
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
|
||||||
@@ -180,7 +180,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
|
|||||||
|
|
||||||
| Store | File area |
|
| Store | File area |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Session JSONL files | `~/.nanobot/sessions/<workspace-hash>/` |
|
| Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
|
||||||
| Long-term memory | `<workspace>/memory/MEMORY.md` |
|
| Long-term memory | `<workspace>/memory/MEMORY.md` |
|
||||||
| Consolidation source history | `<workspace>/memory/history.jsonl` |
|
| Consolidation source history | `<workspace>/memory/history.jsonl` |
|
||||||
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
|
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
|
||||||
|
|||||||
@@ -94,6 +94,24 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
|||||||
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
| `nanobot agent --logs` | Show runtime logs while chatting |
|
||||||
|
|
||||||
|
## Session Storage and Rollback
|
||||||
|
|
||||||
|
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
|
||||||
|
agent-readable workspace. On the first upgraded start, nanobot safely migrates existing
|
||||||
|
`<workspace>/sessions/*.jsonl` files after verifying an atomic copy. Stop every old nanobot
|
||||||
|
process that uses the workspace before upgrading; old and new binaries must not write the
|
||||||
|
same session concurrently.
|
||||||
|
|
||||||
|
To prepare a downgrade, stop nanobot and copy the current sessions back to the path understood
|
||||||
|
by older releases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
The command never deletes the external store and refuses to overwrite a different existing
|
||||||
|
workspace file. Back up both the config directory and workspace before changing versions.
|
||||||
|
|
||||||
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||||
|
|
||||||
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||||
|
|||||||
+8
-2
@@ -27,7 +27,7 @@ The default instance lives under `~/.nanobot/`:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
|
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
|
||||||
| `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
|
| `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
|
||||||
| `~/.nanobot/sessions/<workspace-hash>/` | Session history stored outside the agent-accessible workspace and namespaced by its canonical path |
|
| `~/.nanobot/sessions/<workspace-id>/` | Session history stored outside the agent-accessible workspace; the opaque ID follows workspace moves |
|
||||||
|
|
||||||
You can override both with command flags:
|
You can override both with command flags:
|
||||||
|
|
||||||
@@ -126,11 +126,17 @@ nanobot uses two related stores:
|
|||||||
|
|
||||||
| Store | Location | Purpose |
|
| Store | Location | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Sessions | `~/.nanobot/sessions/<workspace-hash>/*.jsonl` | Recent conversation turns replayed into context |
|
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` | Recent conversation turns replayed into context |
|
||||||
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
|
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
|
||||||
|
|
||||||
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
|
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
|
||||||
|
|
||||||
|
The configured workspace contains a `.nanobot/workspace-id` file. It contains only an
|
||||||
|
opaque random identifier—never conversation content or credentials. Keep it with workspace
|
||||||
|
backups: it lets nanobot find the same external session namespace after the workspace is
|
||||||
|
renamed, moved, or restored. A live copy opened alongside the original receives a new ID so
|
||||||
|
the two workspaces do not share conversations accidentally.
|
||||||
|
|
||||||
See [`memory.md`](./memory.md) for the detailed design.
|
See [`memory.md`](./memory.md) for the detailed design.
|
||||||
|
|
||||||
## Apps and Agent Plugins
|
## Apps and Agent Plugins
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ Check these once before Render, Docker, systemd, or LaunchAgent:
|
|||||||
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
|
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
|
||||||
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
|
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
|
||||||
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
|
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
|
||||||
| `~/.nanobot/` (including `sessions/`) and any custom config/workspace paths are persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
|
| The active config directory (including `sessions/`) and workspace are persistent | Sessions follow `--config`; memory, generated artifacts, and the workspace identity marker follow the workspace |
|
||||||
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
|
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
|
||||||
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
||||||
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
|
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ nanobot gateway logs
|
|||||||
- Docker Compose is the most repeatable Linux container path.
|
- Docker Compose is the most repeatable Linux container path.
|
||||||
- systemd user services are useful for Linux user-level gateway deployments.
|
- systemd user services are useful for Linux user-level gateway deployments.
|
||||||
- macOS LaunchAgent keeps the gateway alive after login.
|
- macOS LaunchAgent keeps the gateway alive after login.
|
||||||
- Persist `~/.nanobot/sessions/` together with config, workspace, memory files,
|
- Persist the active config directory's `sessions/` folder together with the workspace
|
||||||
channel login state, and generated artifacts.
|
(including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
|
||||||
- Restart the gateway after editing `config.json`.
|
- Restart the gateway after editing `config.json`.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
|||||||
|-----------|---------------|---------|
|
|-----------|---------------|---------|
|
||||||
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
|
||||||
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
|
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
|
||||||
|
| **Sessions** | config directory + workspace ID | `~/.nanobot-A/sessions/<workspace-id>/` |
|
||||||
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
|
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
|
||||||
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
||||||
|
|
||||||
@@ -126,6 +127,6 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Each instance must use a different port if they run at the same time
|
- Each instance must use a different port if they run at the same time
|
||||||
- Use a different workspace per instance if you want isolated memory, sessions, and skills
|
- Session data follows the active config directory; use a different workspace per instance to isolate memory, skills, and the stable session namespace ID
|
||||||
- `--workspace` overrides the workspace defined in the config file
|
- `--workspace` overrides the workspace defined in the config file
|
||||||
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
|
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ The WebUI launcher creates or updates:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||||
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
|
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
|
||||||
| `~/.nanobot/sessions/<workspace-hash>/` | Recent session history, isolated by canonical workspace path |
|
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
|
||||||
|
|
||||||
If the installer did not open the browser, run:
|
If the installer did not open the browser, run:
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
|
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
|
||||||
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
|
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
|
||||||
| Old sessions appear after moving config | Session files are stored under `~/.nanobot/sessions/<workspace-hash>/`; verify the canonical workspace path recorded in the directory's `.workspace` marker. |
|
| Sessions disappear after changing `--config` | Sessions follow the config directory at `<config-dir>/sessions/<workspace-id>/`; use the original config path or copy that `sessions/` directory into the new config directory while nanobot is stopped. |
|
||||||
|
| Sessions disappear after moving a workspace | Keep the workspace's `.nanobot/workspace-id` file with the move or backup. If it was lost, restore that marker from backup before starting nanobot. |
|
||||||
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
|
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
|
||||||
|
|
||||||
## Collect Useful Evidence
|
## Collect Useful Evidence
|
||||||
|
|||||||
@@ -476,6 +476,12 @@ class AgentLoop:
|
|||||||
if bus is None:
|
if bus is None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
|
if "session_manager" not in extra:
|
||||||
|
data_dir = config.runtime_data_dir
|
||||||
|
extra["session_manager"] = SessionManager(
|
||||||
|
config.workspace_path,
|
||||||
|
sessions_root=data_dir / "sessions" if data_dir is not None else None,
|
||||||
|
)
|
||||||
provider = extra.pop("provider", None) or make_provider(config)
|
provider = extra.pop("provider", None) or make_provider(config)
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
model = extra.pop("model", None) or resolved.model
|
model = extra.pop("model", None) or resolved.model
|
||||||
|
|||||||
@@ -440,6 +440,44 @@ app.add_typer(
|
|||||||
app.command(name="agent")(agent)
|
app.command(name="agent")(agent)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Session Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
sessions_app = typer.Typer(help="Manage persisted session history")
|
||||||
|
app.add_typer(sessions_app, name="sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@sessions_app.command("restore-workspace")
|
||||||
|
def sessions_restore_workspace(
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
) -> None:
|
||||||
|
"""Copy sessions back into the workspace before downgrading nanobot."""
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
runtime_config = _load_runtime_config(config, workspace)
|
||||||
|
data_dir = runtime_config.runtime_data_dir
|
||||||
|
manager = SessionManager(
|
||||||
|
runtime_config.workspace_path,
|
||||||
|
sessions_root=data_dir / "sessions" if data_dir is not None else None,
|
||||||
|
)
|
||||||
|
result = manager.restore_sessions_to_workspace()
|
||||||
|
console.print(
|
||||||
|
f"Restored {result.restored} session file(s) to "
|
||||||
|
f"{escape(str(runtime_config.workspace_path / 'sessions'))}; "
|
||||||
|
f"{result.unchanged} already matched."
|
||||||
|
)
|
||||||
|
if result.conflicts:
|
||||||
|
console.print(
|
||||||
|
"[red]Rollback is incomplete: existing or invalid files require manual review.[/red]"
|
||||||
|
)
|
||||||
|
for path in result.conflicts:
|
||||||
|
console.print(Text(f"- {path}", style="red"))
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Channel Commands
|
# Channel Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
summary="Environment-based configuration is invalid.",
|
summary="Environment-based configuration is invalid.",
|
||||||
issues=validation_issues(exc),
|
issues=validation_issues(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
config.bind_source_path(path)
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
issues=issues,
|
issues=issues,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
config.bind_source_path(path)
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
||||||
|
|
||||||
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
from pydantic import AliasChoices, ConfigDict, Field, PrivateAttr, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from nanobot.config.timezone import detect_system_timezone
|
from nanobot.config.timezone import detect_system_timezone
|
||||||
@@ -431,6 +431,8 @@ class ToolsConfig(Base):
|
|||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
"""Root configuration for nanobot."""
|
"""Root configuration for nanobot."""
|
||||||
|
|
||||||
|
_source_path: Path | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
||||||
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
||||||
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
|
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
|
||||||
@@ -449,6 +451,15 @@ class Config(BaseSettings):
|
|||||||
_resolve_tool_config_refs()
|
_resolve_tool_config_refs()
|
||||||
super().__init__(**values)
|
super().__init__(**values)
|
||||||
|
|
||||||
|
def bind_source_path(self, path: Path) -> None:
|
||||||
|
"""Record the config file that owns instance-level runtime data."""
|
||||||
|
self._source_path = path.expanduser().resolve(strict=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime_data_dir(self) -> Path | None:
|
||||||
|
"""Return the active instance data directory when loaded from a config path."""
|
||||||
|
return self._source_path.parent if self._source_path is not None else None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
if "default" in self.model_presets:
|
if "default" in self.model_presets:
|
||||||
|
|||||||
+460
-30
@@ -6,7 +6,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import secrets
|
||||||
|
import stat
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -16,9 +17,10 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_legacy_sessions_dir
|
from nanobot.config.paths import get_legacy_sessions_dir, get_runtime_subdir
|
||||||
from nanobot.providers.base import ProviderConversationState
|
from nanobot.providers.base import ProviderConversationState
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
@@ -59,6 +61,11 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
|||||||
"title",
|
"title",
|
||||||
"title_user_edited",
|
"title_user_edited",
|
||||||
}
|
}
|
||||||
|
_WORKSPACE_STATE_DIR = ".nanobot"
|
||||||
|
_WORKSPACE_ID_FILE = "workspace-id"
|
||||||
|
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
|
||||||
|
_COPY_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _json_object(value: object) -> dict[str, Any]:
|
def _json_object(value: object) -> dict[str, Any]:
|
||||||
@@ -505,6 +512,23 @@ class SessionInfo(TypedDict):
|
|||||||
path: str
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SessionFileSnapshot:
|
||||||
|
digest: str
|
||||||
|
size: int
|
||||||
|
mtime_ns: int
|
||||||
|
updated_at: float
|
||||||
|
device: int
|
||||||
|
inode: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionRestoreResult:
|
||||||
|
restored: int
|
||||||
|
unchanged: int
|
||||||
|
conflicts: tuple[Path, ...]
|
||||||
|
|
||||||
|
|
||||||
class SessionStore(Protocol):
|
class SessionStore(Protocol):
|
||||||
def load(self, key: str) -> Session | None: ...
|
def load(self, key: str) -> Session | None: ...
|
||||||
|
|
||||||
@@ -522,34 +546,356 @@ class SessionStore(Protocol):
|
|||||||
class JsonlSessionStore:
|
class JsonlSessionStore:
|
||||||
"""JSONL implementation of session persistence."""
|
"""JSONL implementation of session persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path):
|
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
|
||||||
root = get_legacy_sessions_dir()
|
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
|
||||||
self.sessions_dir = ensure_dir(root / self._workspace_hash(workspace))
|
ensure_dir(canonical_workspace)
|
||||||
self.legacy_sessions_dir = root
|
root = (
|
||||||
self._write_workspace_marker(self.sessions_dir, workspace)
|
Path(sessions_root).expanduser().resolve(strict=False)
|
||||||
self._migrate_from_workspace(workspace)
|
if sessions_root is not None
|
||||||
|
else get_runtime_subdir("sessions").resolve(strict=False)
|
||||||
@staticmethod
|
|
||||||
def _workspace_hash(workspace: Path) -> str:
|
|
||||||
canonical = str(Path(workspace).expanduser().resolve(strict=False))
|
|
||||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_workspace_marker(sessions_dir: Path, workspace: Path) -> None:
|
|
||||||
marker = sessions_dir / ".workspace"
|
|
||||||
if marker.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
marker.write_text(
|
|
||||||
str(Path(workspace).expanduser().resolve(strict=False)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
|
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
|
||||||
|
raise RuntimeError(
|
||||||
|
"session storage must be outside the agent workspace; "
|
||||||
|
"move --config outside --workspace or choose a nested workspace directory"
|
||||||
|
)
|
||||||
|
ensure_dir(root)
|
||||||
|
with suppress(OSError):
|
||||||
|
os.chmod(root, 0o700)
|
||||||
|
self.workspace = canonical_workspace
|
||||||
|
self._migration_lock = FileLock(
|
||||||
|
str(root / ".workspace-migration.lock"),
|
||||||
|
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
with self._migration_lock:
|
||||||
|
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
|
||||||
|
workspace_id = self._claim_workspace_namespace(
|
||||||
|
root,
|
||||||
|
canonical_workspace,
|
||||||
|
workspace_id,
|
||||||
|
)
|
||||||
|
self.sessions_dir = ensure_dir(root / workspace_id)
|
||||||
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
|
self._migrate_from_workspace(canonical_workspace)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fsync_directory(path: Path) -> None:
|
||||||
|
with suppress(PermissionError, NotImplementedError):
|
||||||
|
fd = os.open(path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.debug("Failed to write sessions workspace marker: {}", exc)
|
if exc.errno != errno.EINVAL:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_text_atomic(cls, path: Path, content: str, *, mode: int = 0o600) -> None:
|
||||||
|
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp, "x", encoding="utf-8") as handle:
|
||||||
|
os.chmod(tmp, mode)
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
cls._fsync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _read_workspace_id(cls, marker: Path) -> str:
|
||||||
|
if marker.is_symlink():
|
||||||
|
raise RuntimeError(f"workspace identity marker must not be a symlink: {marker}")
|
||||||
|
value = marker.read_text(encoding="utf-8").strip()
|
||||||
|
if not _WORKSPACE_ID_RE.fullmatch(value):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"workspace identity marker is invalid: {marker}; "
|
||||||
|
"restore its original 32-character identifier before starting nanobot"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _workspace_id_path(workspace: Path) -> Path:
|
||||||
|
state_dir = workspace / _WORKSPACE_STATE_DIR
|
||||||
|
if state_dir.is_symlink():
|
||||||
|
raise RuntimeError(f"workspace state directory must not be a symlink: {state_dir}")
|
||||||
|
ensure_dir(state_dir)
|
||||||
|
return state_dir / _WORKSPACE_ID_FILE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_workspace_namespace(cls, workspace: Path, root: Path) -> str | None:
|
||||||
|
"""Recover an identity marker removed by cleanup at the same workspace path."""
|
||||||
|
matches: list[str] = []
|
||||||
|
for sessions_dir in root.iterdir():
|
||||||
|
if (
|
||||||
|
not _WORKSPACE_ID_RE.fullmatch(sessions_dir.name)
|
||||||
|
or sessions_dir.is_symlink()
|
||||||
|
or not sessions_dir.is_dir()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
marker = sessions_dir / ".workspace"
|
||||||
|
if marker.is_symlink() or not marker.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
recorded = Path(marker.read_text(encoding="utf-8").strip()).expanduser()
|
||||||
|
recorded = recorded.resolve(strict=False)
|
||||||
|
same_workspace = recorded == workspace or (
|
||||||
|
recorded.exists() and recorded.samefile(workspace)
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
continue
|
||||||
|
if same_workspace:
|
||||||
|
matches.append(sessions_dir.name)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"multiple session namespaces claim workspace {workspace}; "
|
||||||
|
"remove the stale namespace marker before starting nanobot"
|
||||||
|
)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_or_create_workspace_id(cls, workspace: Path, root: Path) -> str:
|
||||||
|
marker = cls._workspace_id_path(workspace)
|
||||||
|
if marker.exists() or marker.is_symlink():
|
||||||
|
return cls._read_workspace_id(marker)
|
||||||
|
|
||||||
|
recovered = cls._find_workspace_namespace(workspace, root)
|
||||||
|
if recovered is not None:
|
||||||
|
cls._write_text_atomic(marker, f"{recovered}\n")
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
workspace_id = secrets.token_hex(16)
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
fd = os.open(marker, flags, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
return cls._read_workspace_id(marker)
|
||||||
|
try:
|
||||||
|
payload = f"{workspace_id}\n".encode("ascii")
|
||||||
|
view = memoryview(payload)
|
||||||
|
while view:
|
||||||
|
written = os.write(fd, view)
|
||||||
|
view = view[written:]
|
||||||
|
os.fsync(fd)
|
||||||
|
except BaseException:
|
||||||
|
with suppress(OSError):
|
||||||
|
marker.unlink()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
cls._fsync_directory(marker.parent)
|
||||||
|
return workspace_id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _replace_workspace_id(cls, workspace: Path, workspace_id: str) -> None:
|
||||||
|
cls._write_text_atomic(cls._workspace_id_path(workspace), f"{workspace_id}\n")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_workspace_marker(cls, sessions_dir: Path, workspace: Path) -> None:
|
||||||
|
cls._write_text_atomic(sessions_dir / ".workspace", f"{workspace}\n")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _claim_workspace_namespace(
|
||||||
|
cls,
|
||||||
|
root: Path,
|
||||||
|
workspace: Path,
|
||||||
|
workspace_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""Bind a stable workspace ID, rotating copied live workspaces apart."""
|
||||||
|
for _attempt in range(3):
|
||||||
|
sessions_dir = root / workspace_id
|
||||||
|
marker = sessions_dir / ".workspace"
|
||||||
|
if sessions_dir.is_symlink():
|
||||||
|
raise RuntimeError(f"session namespace must not be a symlink: {sessions_dir}")
|
||||||
|
if not sessions_dir.exists():
|
||||||
|
ensure_dir(sessions_dir)
|
||||||
|
cls._write_workspace_marker(sessions_dir, workspace)
|
||||||
|
return workspace_id
|
||||||
|
if marker.is_symlink():
|
||||||
|
raise RuntimeError(f"session workspace marker must not be a symlink: {marker}")
|
||||||
|
if not marker.exists():
|
||||||
|
if any(sessions_dir.iterdir()):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"session namespace has data but no workspace marker: {sessions_dir}"
|
||||||
|
)
|
||||||
|
cls._write_workspace_marker(sessions_dir, workspace)
|
||||||
|
return workspace_id
|
||||||
|
|
||||||
|
recorded_text = marker.read_text(encoding="utf-8").strip()
|
||||||
|
if not recorded_text:
|
||||||
|
raise RuntimeError(f"session workspace marker is empty: {marker}")
|
||||||
|
recorded = Path(recorded_text).expanduser().resolve(strict=False)
|
||||||
|
if recorded == workspace:
|
||||||
|
return workspace_id
|
||||||
|
try:
|
||||||
|
same_workspace = recorded.exists() and recorded.samefile(workspace)
|
||||||
|
except OSError:
|
||||||
|
same_workspace = False
|
||||||
|
if same_workspace:
|
||||||
|
cls._write_workspace_marker(sessions_dir, workspace)
|
||||||
|
return workspace_id
|
||||||
|
if not recorded.exists():
|
||||||
|
# The identity marker travelled with a renamed or moved workspace.
|
||||||
|
cls._write_workspace_marker(sessions_dir, workspace)
|
||||||
|
return workspace_id
|
||||||
|
|
||||||
|
# Both paths exist and are different: this is a copy, not a move.
|
||||||
|
workspace_id = secrets.token_hex(16)
|
||||||
|
cls._replace_workspace_id(workspace, workspace_id)
|
||||||
|
|
||||||
|
raise RuntimeError(f"could not allocate an isolated session namespace for {workspace}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _session_file_snapshot(path: Path) -> _SessionFileSnapshot | None:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, flags)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
before = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(before.st_mode):
|
||||||
|
return None
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
saw_record = False
|
||||||
|
updated_at: float | None = None
|
||||||
|
with os.fdopen(fd, "rb", closefd=False) as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
digest.update(raw_line)
|
||||||
|
if not raw_line.strip():
|
||||||
|
continue
|
||||||
|
value: object = json.loads(raw_line.decode("utf-8"))
|
||||||
|
data = _json_object(value)
|
||||||
|
saw_record = True
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
raw_updated_at = cast(object, data.get("updated_at"))
|
||||||
|
if isinstance(raw_updated_at, str) and raw_updated_at:
|
||||||
|
updated_at = datetime.fromisoformat(raw_updated_at).timestamp()
|
||||||
|
after = os.fstat(fd)
|
||||||
|
if (
|
||||||
|
not saw_record
|
||||||
|
or before.st_dev != after.st_dev
|
||||||
|
or before.st_ino != after.st_ino
|
||||||
|
or before.st_size != after.st_size
|
||||||
|
or before.st_mtime_ns != after.st_mtime_ns
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return _SessionFileSnapshot(
|
||||||
|
digest=digest.hexdigest(),
|
||||||
|
size=after.st_size,
|
||||||
|
mtime_ns=after.st_mtime_ns,
|
||||||
|
updated_at=(updated_at if updated_at is not None else after.st_mtime_ns / 1e9),
|
||||||
|
device=after.st_dev,
|
||||||
|
inode=after.st_ino,
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prepare_copy(
|
||||||
|
cls,
|
||||||
|
src: Path,
|
||||||
|
dst_dir: Path,
|
||||||
|
snapshot: _SessionFileSnapshot,
|
||||||
|
) -> Path:
|
||||||
|
tmp = dst_dir / f".{src.name}.{secrets.token_hex(8)}.tmp"
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
src_fd = os.open(src, flags)
|
||||||
|
try:
|
||||||
|
before = os.fstat(src_fd)
|
||||||
|
if (
|
||||||
|
before.st_dev != snapshot.device
|
||||||
|
or before.st_ino != snapshot.inode
|
||||||
|
or before.st_size != snapshot.size
|
||||||
|
or before.st_mtime_ns != snapshot.mtime_ns
|
||||||
|
):
|
||||||
|
raise OSError("session source changed before migration")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with os.fdopen(src_fd, "rb", closefd=False) as source, open(tmp, "xb") as target:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
while chunk := source.read(_COPY_CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
target.write(chunk)
|
||||||
|
target.flush()
|
||||||
|
os.fsync(target.fileno())
|
||||||
|
after = os.fstat(src_fd)
|
||||||
|
if (
|
||||||
|
digest.hexdigest() != snapshot.digest
|
||||||
|
or size != snapshot.size
|
||||||
|
or after.st_dev != snapshot.device
|
||||||
|
or after.st_ino != snapshot.inode
|
||||||
|
or after.st_size != snapshot.size
|
||||||
|
or after.st_mtime_ns != snapshot.mtime_ns
|
||||||
|
):
|
||||||
|
raise OSError("session source changed during migration")
|
||||||
|
return tmp
|
||||||
|
except BaseException:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.close(src_fd)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _install_snapshot(
|
||||||
|
cls,
|
||||||
|
src: Path,
|
||||||
|
dst: Path,
|
||||||
|
snapshot: _SessionFileSnapshot,
|
||||||
|
) -> None:
|
||||||
|
tmp = cls._prepare_copy(src, dst.parent, snapshot)
|
||||||
|
try:
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
cls._fsync_directory(dst.parent)
|
||||||
|
installed = cls._session_file_snapshot(dst)
|
||||||
|
if installed is None or installed.digest != snapshot.digest:
|
||||||
|
raise OSError(f"session migration verification failed: {dst}")
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _archive_conflict(
|
||||||
|
self,
|
||||||
|
src: Path,
|
||||||
|
snapshot: _SessionFileSnapshot,
|
||||||
|
label: str,
|
||||||
|
) -> Path:
|
||||||
|
conflict_dir = ensure_dir(self.sessions_dir / ".migration-conflicts")
|
||||||
|
conflict = conflict_dir / (
|
||||||
|
f"{src.stem}.{label}.{snapshot.digest[:12]}.{secrets.token_hex(4)}.jsonl"
|
||||||
|
)
|
||||||
|
self._install_snapshot(src, conflict, snapshot)
|
||||||
|
return conflict
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _remove_migrated_source(
|
||||||
|
cls,
|
||||||
|
src: Path,
|
||||||
|
snapshot: _SessionFileSnapshot,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
current = src.stat(follow_symlinks=False)
|
||||||
|
if (
|
||||||
|
current.st_dev != snapshot.device
|
||||||
|
or current.st_ino != snapshot.inode
|
||||||
|
or current.st_size != snapshot.size
|
||||||
|
or current.st_mtime_ns != snapshot.mtime_ns
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
src.unlink()
|
||||||
|
cls._fsync_directory(src.parent)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _migrate_from_workspace(self, workspace: Path) -> None:
|
def _migrate_from_workspace(self, workspace: Path) -> None:
|
||||||
"""Move legacy in-workspace session files into the out-of-workspace store."""
|
"""Durably copy legacy sessions out of the workspace, then remove the source."""
|
||||||
old_dir = Path(workspace).expanduser() / "sessions"
|
old_dir = workspace / "sessions"
|
||||||
if old_dir.is_symlink() or not old_dir.is_dir():
|
if old_dir.is_symlink() or not old_dir.is_dir():
|
||||||
if old_dir.is_symlink():
|
if old_dir.is_symlink():
|
||||||
logger.warning("Skipping symlinked legacy sessions directory: {}", old_dir)
|
logger.warning("Skipping symlinked legacy sessions directory: {}", old_dir)
|
||||||
@@ -559,13 +905,87 @@ class JsonlSessionStore:
|
|||||||
logger.warning("Skipping unsafe legacy session file: {}", src)
|
logger.warning("Skipping unsafe legacy session file: {}", src)
|
||||||
continue
|
continue
|
||||||
dst = self.sessions_dir / src.name
|
dst = self.sessions_dir / src.name
|
||||||
if dst.exists():
|
source_snapshot = self._session_file_snapshot(src)
|
||||||
|
if source_snapshot is None:
|
||||||
|
logger.warning("Skipping invalid or changing legacy session file: {}", src)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
shutil.move(str(src), str(dst))
|
destination_snapshot = self._session_file_snapshot(dst) if dst.exists() else None
|
||||||
|
if dst.exists() and destination_snapshot is None:
|
||||||
|
logger.warning(
|
||||||
|
"Keeping legacy session because destination is invalid: {}",
|
||||||
|
dst,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if destination_snapshot is None:
|
||||||
|
self._install_snapshot(src, dst, source_snapshot)
|
||||||
|
elif destination_snapshot.digest == source_snapshot.digest:
|
||||||
|
pass
|
||||||
|
elif source_snapshot.updated_at > destination_snapshot.updated_at:
|
||||||
|
archived = self._archive_conflict(dst, destination_snapshot, "destination")
|
||||||
|
self._install_snapshot(src, dst, source_snapshot)
|
||||||
|
logger.warning("Archived older session migration conflict at {}", archived)
|
||||||
|
else:
|
||||||
|
archived = self._archive_conflict(src, source_snapshot, "workspace")
|
||||||
|
logger.warning("Archived older session migration conflict at {}", archived)
|
||||||
|
|
||||||
|
installed = self._session_file_snapshot(dst)
|
||||||
|
if installed is None:
|
||||||
|
raise OSError(f"session migration destination is unreadable: {dst}")
|
||||||
|
selected_digest = (
|
||||||
|
source_snapshot.digest
|
||||||
|
if destination_snapshot is None
|
||||||
|
or source_snapshot.updated_at > destination_snapshot.updated_at
|
||||||
|
else destination_snapshot.digest
|
||||||
|
)
|
||||||
|
if installed.digest != selected_digest:
|
||||||
|
raise OSError(f"session migration selected unexpected data: {dst}")
|
||||||
|
if not self._remove_migrated_source(src, source_snapshot):
|
||||||
|
logger.warning(
|
||||||
|
"Session migrated but legacy source changed or could not be removed: {}",
|
||||||
|
src,
|
||||||
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("Failed to migrate session {}: {}", src, exc)
|
logger.warning("Failed to migrate session {}: {}", src, exc)
|
||||||
|
|
||||||
|
def restore_to_workspace(self) -> SessionRestoreResult:
|
||||||
|
"""Copy canonical sessions back for an explicit downgrade or rollback."""
|
||||||
|
restored = 0
|
||||||
|
unchanged = 0
|
||||||
|
conflicts: list[Path] = []
|
||||||
|
old_dir = self.workspace / "sessions"
|
||||||
|
if old_dir.is_symlink():
|
||||||
|
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
|
||||||
|
ensure_dir(old_dir)
|
||||||
|
|
||||||
|
with self._migration_lock:
|
||||||
|
for src in self.sessions_dir.glob("*.jsonl"):
|
||||||
|
if self.session_key_from_path(src) is None:
|
||||||
|
continue
|
||||||
|
source_snapshot = self._session_file_snapshot(src)
|
||||||
|
if source_snapshot is None:
|
||||||
|
conflicts.append(src)
|
||||||
|
continue
|
||||||
|
dst = old_dir / src.name
|
||||||
|
if dst.exists():
|
||||||
|
destination_snapshot = self._session_file_snapshot(dst)
|
||||||
|
if (
|
||||||
|
destination_snapshot is not None
|
||||||
|
and destination_snapshot.digest == source_snapshot.digest
|
||||||
|
):
|
||||||
|
unchanged += 1
|
||||||
|
else:
|
||||||
|
conflicts.append(dst)
|
||||||
|
continue
|
||||||
|
self._install_snapshot(src, dst, source_snapshot)
|
||||||
|
restored += 1
|
||||||
|
return SessionRestoreResult(
|
||||||
|
restored=restored,
|
||||||
|
unchanged=unchanged,
|
||||||
|
conflicts=tuple(conflicts),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def safe_key(key: str) -> str:
|
def safe_key(key: str) -> str:
|
||||||
return safe_filename(key.replace(":", "_"))
|
return safe_filename(key.replace(":", "_"))
|
||||||
@@ -1033,9 +1453,15 @@ class JsonlSessionStore:
|
|||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manage session identity, caching, retention, and persistence."""
|
"""Manage session identity, caching, retention, and persistence."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
store: SessionStore | None = None,
|
||||||
|
sessions_root: Path | None = None,
|
||||||
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self._jsonl_store = JsonlSessionStore(workspace)
|
self._jsonl_store = JsonlSessionStore(workspace, sessions_root=sessions_root)
|
||||||
self._store: SessionStore = store if store is not None else self._jsonl_store
|
self._store: SessionStore = store if store is not None else self._jsonl_store
|
||||||
self.sessions_dir = self._jsonl_store.sessions_dir
|
self.sessions_dir = self._jsonl_store.sessions_dir
|
||||||
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
|
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
|
||||||
@@ -1201,6 +1627,10 @@ class SessionManager:
|
|||||||
self.invalidate(key)
|
self.invalidate(key)
|
||||||
return self._store.delete(key)
|
return self._store.delete(key)
|
||||||
|
|
||||||
|
def restore_sessions_to_workspace(self) -> SessionRestoreResult:
|
||||||
|
"""Restore session files to the pre-relocation path for an explicit rollback."""
|
||||||
|
return self._jsonl_store.restore_to_workspace()
|
||||||
|
|
||||||
def fork_session_before_user_index(
|
def fork_session_before_user_index(
|
||||||
self,
|
self,
|
||||||
source_key: str,
|
source_key: str,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.cli import commands
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_sessions_restore_workspace_command_prepares_downgrade(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
config_path = tmp_path / "instance" / "config.json"
|
||||||
|
config = load_config(config_path)
|
||||||
|
config.agents.defaults.workspace = str(workspace)
|
||||||
|
manager = SessionManager(workspace, sessions_root=config_path.parent / "sessions")
|
||||||
|
session = manager.get_or_create("cli:rollback")
|
||||||
|
session.add_message("user", "restore-me")
|
||||||
|
manager.save(session, fsync=True)
|
||||||
|
monkeypatch.setattr(commands, "_load_runtime_config", lambda *_args: config)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(commands.app, ["sessions", "restore-workspace"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Restored 1 session file(s)" in result.output
|
||||||
|
restored = workspace / "sessions" / manager._get_session_path(session.key).name
|
||||||
|
assert restored.exists()
|
||||||
|
assert "restore-me" in restored.read_text(encoding="utf-8")
|
||||||
|
assert manager._get_session_path(session.key).exists()
|
||||||
@@ -3,14 +3,29 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.errors import ConfigLoadError
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
from nanobot.config.schema import ApiConfig
|
from nanobot.config.schema import ApiConfig
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||||
config = load_config(tmp_path / "missing.json")
|
config_path = tmp_path / "instance" / "missing.json"
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.model
|
assert config.agents.defaults.model
|
||||||
|
assert config.runtime_data_dir == config_path.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_resolution_preserves_config_runtime_data_dir(
|
||||||
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "instance" / "config.json"
|
||||||
|
config_path.parent.mkdir()
|
||||||
|
config_path.write_text('{"providers": {"openai": {"apiKey": "${TEST_API_KEY}"}}}')
|
||||||
|
monkeypatch.setenv("TEST_API_KEY", "resolved")
|
||||||
|
|
||||||
|
config = resolve_config_env_vars(load_config(config_path), config_path=config_path)
|
||||||
|
|
||||||
|
assert config.runtime_data_dir == config_path.parent
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_reports_malformed_environment_safely(
|
def test_load_config_reports_malformed_environment_safely(
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.session.manager import JsonlSessionStore, SessionManager
|
from nanobot.session.manager import JsonlSessionStore, SessionManager
|
||||||
|
|
||||||
|
|
||||||
def _write_legacy_session(old_dir: Path, key: str, content: str) -> Path:
|
def _write_legacy_session(
|
||||||
|
old_dir: Path,
|
||||||
|
key: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
updated_at: str = "2026-01-01T00:00:00",
|
||||||
|
) -> Path:
|
||||||
"""Write a valid session file in the legacy in-workspace location."""
|
"""Write a valid session file in the legacy in-workspace location."""
|
||||||
old_dir.mkdir(parents=True, exist_ok=True)
|
old_dir.mkdir(parents=True, exist_ok=True)
|
||||||
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
|
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
|
||||||
@@ -20,7 +30,7 @@ def _write_legacy_session(old_dir: Path, key: str, content: str) -> Path:
|
|||||||
"_type": "metadata",
|
"_type": "metadata",
|
||||||
"key": key,
|
"key": key,
|
||||||
"created_at": "2026-01-01T00:00:00",
|
"created_at": "2026-01-01T00:00:00",
|
||||||
"updated_at": "2026-01-01T00:00:00",
|
"updated_at": updated_at,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"last_consolidated": 0,
|
"last_consolidated": 0,
|
||||||
}
|
}
|
||||||
@@ -45,15 +55,37 @@ def test_sessions_are_stored_outside_workspace(tmp_path: Path) -> None:
|
|||||||
workspace_sessions = workspace / "sessions"
|
workspace_sessions = workspace / "sessions"
|
||||||
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
|
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
|
||||||
|
|
||||||
# The out-of-workspace store records which workspace it belongs to.
|
# The out-of-workspace store records which workspace it belongs to and the
|
||||||
|
# workspace carries only a non-secret stable identity marker.
|
||||||
marker = manager.sessions_dir / ".workspace"
|
marker = manager.sessions_dir / ".workspace"
|
||||||
assert marker.read_text(encoding="utf-8") == str(workspace.resolve())
|
assert marker.read_text(encoding="utf-8").strip() == str(workspace.resolve())
|
||||||
|
workspace_id = (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip()
|
||||||
|
assert manager.sessions_dir.name == workspace_id
|
||||||
|
assert manager.sessions_dir.parent.name == "sessions"
|
||||||
|
|
||||||
# And it must still round-trip through a fresh manager for the same workspace.
|
# And it must still round-trip through a fresh manager for the same workspace.
|
||||||
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
|
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
|
||||||
assert reloaded.messages[-1]["content"] == "hello"
|
assert reloaded.messages[-1]["content"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_identity_marker_contains_no_session_content(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
secret = f"session-secret-{uuid.uuid4()}"
|
||||||
|
manager = SessionManager(workspace=workspace)
|
||||||
|
session = manager.get_or_create("telegram:secret")
|
||||||
|
session.add_message("user", secret)
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
marker = workspace / ".nanobot" / "workspace-id"
|
||||||
|
assert marker.read_text(encoding="utf-8").strip() == manager.sessions_dir.name
|
||||||
|
assert secret not in marker.read_text(encoding="utf-8")
|
||||||
|
assert not any(
|
||||||
|
secret in path.read_text(encoding="utf-8")
|
||||||
|
for path in workspace.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
|
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
|
||||||
workspace_a = tmp_path / "ws_a"
|
workspace_a = tmp_path / "ws_a"
|
||||||
workspace_b = tmp_path / "ws_b"
|
workspace_b = tmp_path / "ws_b"
|
||||||
@@ -69,6 +101,94 @@ def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
|
|||||||
assert in_b.messages == []
|
assert in_b.messages == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sessions_follow_active_custom_config_data_root(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
custom_instance = tmp_path / "instance-b"
|
||||||
|
custom_config = custom_instance / "config.json"
|
||||||
|
default_home = tmp_path / "read-only-home"
|
||||||
|
default_home.mkdir()
|
||||||
|
default_home.chmod(0o500)
|
||||||
|
monkeypatch.setenv("HOME", str(default_home))
|
||||||
|
config = load_config(custom_config)
|
||||||
|
data_dir = config.runtime_data_dir
|
||||||
|
assert data_dir == custom_instance
|
||||||
|
|
||||||
|
manager = SessionManager(
|
||||||
|
workspace=tmp_path / "workspace-b",
|
||||||
|
sessions_root=data_dir / "sessions",
|
||||||
|
)
|
||||||
|
session = manager.get_or_create("telegram:custom")
|
||||||
|
session.add_message("user", "custom-instance")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
assert manager.sessions_dir.parent == custom_instance / "sessions"
|
||||||
|
assert manager._get_session_path(session.key).exists()
|
||||||
|
assert not (default_home / ".nanobot" / "sessions").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_root_inside_workspace_fails_closed(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="must be outside the agent workspace"):
|
||||||
|
SessionManager(workspace=workspace, sessions_root=workspace / "sessions")
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_move_preserves_session_identity(tmp_path: Path) -> None:
|
||||||
|
original = tmp_path / "project-old"
|
||||||
|
manager = SessionManager(workspace=original)
|
||||||
|
session = manager.get_or_create("telegram:1")
|
||||||
|
session.add_message("user", "survives-move")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
moved = tmp_path / "project-new"
|
||||||
|
original.rename(moved)
|
||||||
|
reloaded = SessionManager(workspace=moved)
|
||||||
|
|
||||||
|
assert reloaded.sessions_dir == manager.sessions_dir
|
||||||
|
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-move"
|
||||||
|
assert (reloaded.sessions_dir / ".workspace").read_text(encoding="utf-8").strip() == str(
|
||||||
|
moved.resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleted_workspace_identity_marker_is_recovered(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "project"
|
||||||
|
manager = SessionManager(workspace=workspace)
|
||||||
|
session = manager.get_or_create("telegram:1")
|
||||||
|
session.add_message("user", "survives-cleanup")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
shutil.rmtree(workspace / ".nanobot")
|
||||||
|
reloaded = SessionManager(workspace=workspace)
|
||||||
|
|
||||||
|
assert reloaded.sessions_dir == manager.sessions_dir
|
||||||
|
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-cleanup"
|
||||||
|
assert (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip() == (
|
||||||
|
manager.sessions_dir.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_copied_workspace_gets_isolated_session_identity(tmp_path: Path) -> None:
|
||||||
|
original = tmp_path / "project-a"
|
||||||
|
original.mkdir()
|
||||||
|
manager = SessionManager(workspace=original)
|
||||||
|
session = manager.get_or_create("telegram:1")
|
||||||
|
session.add_message("user", "secret-for-a")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
copied = tmp_path / "project-b"
|
||||||
|
shutil.copytree(original, copied)
|
||||||
|
copied_manager = SessionManager(workspace=copied)
|
||||||
|
|
||||||
|
assert copied_manager.sessions_dir != manager.sessions_dir
|
||||||
|
assert copied_manager.get_or_create("telegram:1").messages == []
|
||||||
|
assert (copied / ".nanobot" / "workspace-id").read_text(encoding="utf-8") != (
|
||||||
|
original / ".nanobot" / "workspace-id"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
|
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
|
||||||
real_workspace = tmp_path / "real_ws"
|
real_workspace = tmp_path / "real_ws"
|
||||||
real_workspace.mkdir()
|
real_workspace.mkdir()
|
||||||
@@ -105,6 +225,74 @@ def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
|
|||||||
assert again.messages[-1]["content"] == "migrated-msg"
|
assert again.messages[-1]["content"] == "migrated-msg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_keeps_source_when_install_fails(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
key = "telegram:partial"
|
||||||
|
old_file = _write_legacy_session(workspace / "sessions", key, "still-safe")
|
||||||
|
|
||||||
|
with patch.object(JsonlSessionStore, "_install_snapshot", side_effect=OSError("disk full")):
|
||||||
|
manager = SessionManager(workspace=workspace)
|
||||||
|
|
||||||
|
assert old_file.exists()
|
||||||
|
assert not (manager.sessions_dir / old_file.name).exists()
|
||||||
|
|
||||||
|
retried = SessionManager(workspace=workspace)
|
||||||
|
assert retried.get_or_create(key).messages[-1]["content"] == "still-safe"
|
||||||
|
assert not old_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_preserves_newest_valid_conflict(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
key = "telegram:conflict"
|
||||||
|
old_file = _write_legacy_session(workspace / "sessions", key, "newer-workspace")
|
||||||
|
manager = SessionManager(workspace=workspace)
|
||||||
|
|
||||||
|
# Recreate an older legacy source while a newer destination already exists.
|
||||||
|
manager_session = manager.get_or_create(key)
|
||||||
|
manager_session.add_message("assistant", "newer-destination")
|
||||||
|
manager.save(manager_session)
|
||||||
|
_write_legacy_session(
|
||||||
|
workspace / "sessions",
|
||||||
|
key,
|
||||||
|
"older-workspace",
|
||||||
|
updated_at="2025-01-01T00:00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
retried = SessionManager(workspace=workspace)
|
||||||
|
loaded = retried.get_or_create(key)
|
||||||
|
|
||||||
|
assert loaded.messages[-1]["content"] == "newer-destination"
|
||||||
|
conflicts = list((retried.sessions_dir / ".migration-conflicts").glob("*.jsonl"))
|
||||||
|
assert len(conflicts) == 1
|
||||||
|
assert "older-workspace" in conflicts[0].read_text(encoding="utf-8")
|
||||||
|
assert not old_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_rollback_restore_copies_sessions_back_without_deleting_new_store(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
manager = SessionManager(workspace=workspace)
|
||||||
|
session = manager.get_or_create("telegram:rollback")
|
||||||
|
session.add_message("user", "available-to-old-version")
|
||||||
|
manager.save(session, fsync=True)
|
||||||
|
|
||||||
|
result = manager.restore_sessions_to_workspace()
|
||||||
|
legacy_file = workspace / "sessions" / manager._get_session_path(session.key).name
|
||||||
|
|
||||||
|
assert result.restored == 1
|
||||||
|
assert result.unchanged == 0
|
||||||
|
assert result.conflicts == ()
|
||||||
|
assert legacy_file.exists()
|
||||||
|
assert manager._get_session_path(session.key).exists()
|
||||||
|
assert "available-to-old-version" in legacy_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
repeated = manager.restore_sessions_to_workspace()
|
||||||
|
assert repeated.restored == 0
|
||||||
|
assert repeated.unchanged == 1
|
||||||
|
assert repeated.conflicts == ()
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_migration_rejects_symlinked_session_file(tmp_path: Path) -> None:
|
def test_legacy_migration_rejects_symlinked_session_file(tmp_path: Path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
old_dir = workspace / "sessions"
|
old_dir = workspace / "sessions"
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
|||||||
}
|
}
|
||||||
if overrides:
|
if overrides:
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
config_path = tmp_path / "config.json"
|
config_dir = tmp_path.parent / f"{tmp_path.name}-instance"
|
||||||
|
config_dir.mkdir(exist_ok=True)
|
||||||
|
config_path = config_dir / "config.json"
|
||||||
config_path.write_text(json.dumps(data))
|
config_path.write_text(json.dumps(data))
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
@@ -96,9 +98,11 @@ def test_from_config_missing_env_reports_explicit_config_path(
|
|||||||
|
|
||||||
def test_from_config_creates_instance(tmp_path):
|
def test_from_config_creates_instance(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
workspace = tmp_path / "workspace"
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=workspace)
|
||||||
assert bot._loop is not None
|
assert bot._loop is not None
|
||||||
assert bot._loop.workspace == tmp_path
|
assert bot._loop.workspace == workspace
|
||||||
|
assert bot._loop.sessions.sessions_dir.parent == config_path.parent / "sessions"
|
||||||
|
|
||||||
|
|
||||||
def test_from_config_composes_configured_mcp_outside_agent_loop(tmp_path):
|
def test_from_config_composes_configured_mcp_outside_agent_loop(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user