nanobot/nanobot/sdk/clients.py
2026-06-21 16:55:23 +08:00

166 lines
5.9 KiB
Python

"""Small convenience clients exposed by the high-level Python SDK."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from nanobot.sdk.types import (
SessionInfo,
SessionSnapshot,
snapshot_from_payload,
snapshot_from_session,
)
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
class SessionClient:
"""Session management helpers exposed through ``bot.sessions``."""
_RESERVED_MESSAGE_KEYS = {"role", "content"}
_VALID_ROLES = {"user", "assistant", "tool", "system"}
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
async def ingest(
self,
session_key: str,
messages: Iterable[Mapping[str, Any]],
*,
metadata: Mapping[str, Any] | None = None,
source: str | None = None,
save: bool = True,
) -> SessionSnapshot:
"""Import an existing transcript without running the model."""
session = self._loop.sessions.get_or_create(session_key)
if metadata:
session.metadata.update(deepcopy(dict(metadata)))
for raw in messages:
if "role" not in raw:
raise ValueError("ingested messages must include a role")
if "content" not in raw:
raise ValueError("ingested messages must include content")
role = str(raw["role"]).strip()
if role not in self._VALID_ROLES:
raise ValueError(f"unsupported message role: {role!r}")
extra = {
key: deepcopy(value)
for key, value in raw.items()
if key not in self._RESERVED_MESSAGE_KEYS
}
if source is not None and "source" not in extra:
extra["source"] = source
session.add_message(role, deepcopy(raw["content"]), **extra)
if save:
self._loop.sessions.save(session)
return snapshot_from_session(session)
def get(self, session_key: str) -> SessionSnapshot | None:
"""Return a session snapshot without creating a new session on disk."""
cached = self._loop.sessions._cache.get(session_key)
if cached is not None:
return snapshot_from_session(cached)
payload = self._loop.sessions.read_session_file(session_key)
if payload is None:
return None
return snapshot_from_payload(payload)
def list(self) -> list[SessionInfo]:
"""List persisted sessions."""
return [
SessionInfo(
key=str(row.get("key") or ""),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
title=str(row.get("title") or ""),
preview=str(row.get("preview") or ""),
path=row.get("path"),
)
for row in self._loop.sessions.list_sessions()
]
def export(self, session_key: str) -> SessionSnapshot | None:
"""Return a full session snapshot suitable for JSON serialization."""
return self.get(session_key)
def clear(self, session_key: str) -> SessionSnapshot:
"""Clear one session and persist the empty session."""
session = self._loop.sessions.get_or_create(session_key)
session.clear()
self._loop.sessions.save(session)
return snapshot_from_session(session)
def delete(self, session_key: str) -> bool:
"""Delete one session from disk and cache."""
return self._loop.sessions.delete_session(session_key)
def flush(self) -> int:
"""Flush cached sessions to durable storage."""
return self._loop.sessions.flush_all()
class MemoryClient:
"""Long-term memory helpers exposed through ``bot.memory``."""
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
def read(self) -> str:
"""Read ``memory/MEMORY.md``."""
return self._loop.context.memory.read_memory()
def write(self, text: str) -> None:
"""Overwrite ``memory/MEMORY.md``."""
self._loop.context.memory.write_memory(text)
def append_history(self, text: str, *, session_key: str | None = None) -> int:
"""Append one entry to ``memory/history.jsonl`` and return its cursor."""
return self._loop.context.memory.append_history(text, session_key=session_key)
def read_history(self, *, session_key: str | None = None) -> list[dict[str, Any]]:
"""Read memory history entries, optionally filtered by session."""
entries = self._loop.context.memory.read_unprocessed_history(since_cursor=0)
if session_key is not None:
entries = [entry for entry in entries if entry.get("session_key") == session_key]
return deepcopy(entries)
class RuntimeClient:
"""Runtime control helpers exposed through ``bot.runtime``."""
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
@property
def model(self) -> str:
"""Current runtime model name."""
return self._loop.model
@property
def workspace(self) -> Path:
"""Current runtime workspace."""
return self._loop.workspace
async def compact_session(self, session_key: str) -> SessionSnapshot:
"""Run token/replay-window consolidation for one session."""
session = self._loop.sessions.get_or_create(session_key)
await self._loop.consolidator.maybe_consolidate_by_tokens(
session,
replay_max_messages=self._loop._max_messages,
)
return snapshot_from_session(self._loop.sessions.get_or_create(session_key))
async def compact_idle_session(self, session_key: str, *, max_suffix: int = 8) -> str | None:
"""Run idle-session compaction for one session and return the summary."""
return await self._loop.consolidator.compact_idle_session(
session_key,
max_suffix=max_suffix,
)