e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
1292 lines
52 KiB
Python
1292 lines
52 KiB
Python
"""Partner Manager — create / start / stop / manage in-process partners.
|
|
|
|
Each partner runs as a set of asyncio tasks within the DeepTutor server
|
|
process: a ``PartnerRunner`` (chat-agent-loop driver), an outbound message
|
|
router, and one listener task per enabled IM channel. Every partner owns an
|
|
isolated chat-format workspace under ``data/partners/{partner_id}/``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
import hashlib
|
|
import logging
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
import yaml
|
|
|
|
from deeptutor.partners.config.paths import (
|
|
get_data_dir,
|
|
get_partner_dir,
|
|
get_partner_sessions_dir,
|
|
)
|
|
from deeptutor.services.partners.runtime import PartnerRunner
|
|
from deeptutor.services.partners.sessions import PartnerSessionStore
|
|
from deeptutor.services.partners.workspace import (
|
|
DEFAULT_SOUL,
|
|
ensure_partner_workspace,
|
|
read_soul,
|
|
write_soul,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_RESERVED_NAMES = {"workspace", "media", "sessions", "_souls"}
|
|
_HYPHEN_RUN_RE = re.compile(r"-+")
|
|
LEGACY_GLOBAL_DELIVERY_KEYS = frozenset(
|
|
{"send_progress", "send_tool_hints", "sendProgress", "sendToolHints"}
|
|
)
|
|
|
|
# Substrings (case-insensitive) on channel field names that flag a value as a
|
|
# secret which must be masked before being serialised in non-edit responses.
|
|
# Matches existing channel configs: telegram.token, slack.bot_token /
|
|
# slack.app_token, discord.token, matrix.access_token, whatsapp.bridge_token,
|
|
# mochat.claw_token, feishu.app_secret / encrypt_key / verification_token,
|
|
# wecom.secret, qq.secret, dingtalk.client_secret, email.imap_password /
|
|
# smtp_password, etc.
|
|
_SECRET_FIELD_HINTS: tuple[str, ...] = (
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"api_key",
|
|
"apikey",
|
|
"encrypt_key",
|
|
)
|
|
_SECRET_MASK = "***"
|
|
|
|
|
|
def _is_secret_field(name: str) -> bool:
|
|
n = name.lower()
|
|
return any(hint in n for hint in _SECRET_FIELD_HINTS)
|
|
|
|
|
|
def mask_channel_secrets(channels: dict[str, Any]) -> dict[str, Any]:
|
|
"""Deep-copy ``channels`` and replace any secret-looking string field with ``***``."""
|
|
|
|
def _walk(value: Any, key_hint: str | None = None) -> Any:
|
|
if isinstance(value, dict):
|
|
return {k: _walk(v, key_hint=k) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_walk(v, key_hint=key_hint) for v in value]
|
|
if isinstance(value, tuple):
|
|
return tuple(_walk(v, key_hint=key_hint) for v in value)
|
|
if key_hint is not None and _is_secret_field(key_hint) and isinstance(value, str) and value:
|
|
return _SECRET_MASK
|
|
return value
|
|
|
|
walked = _walk(channels)
|
|
if not isinstance(walked, dict): # defensive — should not happen
|
|
return {}
|
|
return walked
|
|
|
|
|
|
def strip_legacy_global_delivery(channels: dict[str, Any]) -> dict[str, Any]:
|
|
"""Remove deprecated top-level delivery switches from channel config."""
|
|
if not isinstance(channels, dict):
|
|
return {}
|
|
return {k: v for k, v in channels.items() if k not in LEGACY_GLOBAL_DELIVERY_KEYS}
|
|
|
|
|
|
def _slugify_id(name: str, *, fallback: str) -> str:
|
|
# Ids ride in URLs (``/partners/<id>``, ``/souls/<id>``) and can become
|
|
# on-disk names, so they must be ASCII/URL-safe: a non-Latin id (e.g.
|
|
# ``中文``) yields a link the browser can't cleanly display or share, which
|
|
# left CJK-named entities unreachable. Keep ASCII letters/digits
|
|
# (lower-cased) and collapse every other run to a single hyphen —
|
|
# ``isascii`` drops CJK/other scripts and ``isalnum`` excludes underscore,
|
|
# so a partner id can't collide with the reserved ``_souls`` directory. When
|
|
# no ASCII survives (a pure-CJK name), fall back to a stable per-name handle
|
|
# so distinct non-Latin names still get distinct ids while the same name
|
|
# round-trips to the same id (keeping the create-time duplicate check
|
|
# meaningful). Only the id is slugged; the entity keeps its display name.
|
|
stripped = name.strip()
|
|
cleaned = "".join(c if c.isascii() and c.isalnum() else "-" for c in stripped.lower())
|
|
slug = _HYPHEN_RUN_RE.sub("-", cleaned).strip("-")
|
|
if slug:
|
|
return slug
|
|
if not stripped:
|
|
return fallback
|
|
# SHA1 here is a content-addressing handle (stable per-name id), not
|
|
# security; ``usedforsecurity=False`` documents that and clears bandit B324.
|
|
digest = hashlib.sha1(stripped.encode("utf-8"), usedforsecurity=False).hexdigest()[:8]
|
|
return f"{fallback}-{digest}"
|
|
|
|
|
|
def slugify_partner_id(name: str) -> str:
|
|
"""ASCII/URL-safe partner id derived from a display name (see
|
|
:func:`_slugify_id`); pure-CJK names get a stable ``partner-<hash>`` id."""
|
|
return _slugify_id(name, fallback="partner")
|
|
|
|
|
|
def slugify_soul_id(name: str) -> str:
|
|
"""ASCII/URL-safe soul-library id. Soul ids ride in ``/souls/<id>`` URLs
|
|
exactly like partner ids, so they get the same treatment (see
|
|
:func:`_slugify_id`); pure-CJK names get a stable ``soul-<hash>`` id."""
|
|
return _slugify_id(name, fallback="soul")
|
|
|
|
|
|
def _optional_str_list(value: Any) -> list[str] | None:
|
|
"""YAML field → ``None`` (absent / wrong type) or a list of strings."""
|
|
if isinstance(value, list):
|
|
return [str(item) for item in value]
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class PartnerConfig:
|
|
"""Configuration for a single partner."""
|
|
|
|
name: str
|
|
description: str = ""
|
|
channels: dict[str, Any] = field(default_factory=dict)
|
|
llm_selection: dict[str, str] | None = None
|
|
# Fallback model: when a turn fails outright on the primary selection
|
|
# (LLM error, no output), the runner re-runs the turn once with this.
|
|
backup_llm_selection: dict[str, str] | None = None
|
|
model: str | None = None # legacy TutorBot model-string override
|
|
language: str = ""
|
|
emoji: str = ""
|
|
color: str = ""
|
|
# Custom avatar as a compact data URL (image/svg, client-resized) —
|
|
# kept inline in config so <img> rendering needs no authenticated
|
|
# file endpoint. Takes precedence over emoji/color when set.
|
|
avatar: str = ""
|
|
soul_origin: dict[str, str] = field(default_factory=dict) # {"type","id"} provenance
|
|
# User-toggleable system tools (same pool as the chat composer /
|
|
# /settings/tools). None = all of them; [] = none; list = whitelist.
|
|
enabled_tools: list[str] | None = None
|
|
# Allowed built-in (auto-mounted) tools — rag / read_memory / web_fetch /
|
|
# … (CONFIGURABLE_BUILTIN_TOOL_NAMES). None = no gating (all mount under
|
|
# their usual context condition, like the product chat); [] = deny every
|
|
# built-in; list = whitelist. Lets an owner deny e.g. memory to an
|
|
# IM-facing partner.
|
|
builtin_tools: list[str] | None = None
|
|
# Configured MCP tools the partner may load. None = all; [] = MCP off.
|
|
mcp_tools: list[str] | None = None
|
|
|
|
|
|
@dataclass
|
|
class LiveTurn:
|
|
"""A web turn decoupled from any single WebSocket connection.
|
|
|
|
The turn runs as a task on the partner instance and fans its stream frames
|
|
out to per-subscriber queues, buffering them too. A client that reconnects
|
|
mid-turn (a page refresh) re-subscribes and replays the buffer, so the
|
|
in-progress answer survives the reload instead of dying with the old
|
|
socket. Frames are the same shapes the socket already sends
|
|
(``stream_event`` / ``content`` / ``done`` / ``stopped`` / ``error``).
|
|
"""
|
|
|
|
# The user message that opened the turn, so a client reattaching after a
|
|
# refresh can re-show the question bubble (it isn't persisted until the
|
|
# turn completes).
|
|
user_content: str = ""
|
|
events: list[dict[str, Any]] = field(default_factory=list)
|
|
terminal: list[dict[str, Any]] = field(default_factory=list)
|
|
done: bool = False
|
|
subscribers: set[asyncio.Queue] = field(default_factory=set, repr=False)
|
|
task: asyncio.Task | None = field(default=None, repr=False)
|
|
|
|
def emit(self, frame: dict[str, Any]) -> None:
|
|
self.events.append(frame)
|
|
for queue in self.subscribers:
|
|
queue.put_nowait(frame)
|
|
|
|
def finish(self, frames: list[dict[str, Any]]) -> None:
|
|
self.terminal = frames
|
|
self.done = True
|
|
for queue in self.subscribers:
|
|
for frame in frames:
|
|
queue.put_nowait(frame)
|
|
self.subscribers.clear()
|
|
|
|
def subscribe(self) -> asyncio.Queue:
|
|
"""Preload the backlog and register — atomically (no await between) so
|
|
no frame is missed or duplicated at the boundary."""
|
|
queue: asyncio.Queue = asyncio.Queue()
|
|
for frame in (*self.events, *self.terminal):
|
|
queue.put_nowait(frame)
|
|
if not self.done:
|
|
self.subscribers.add(queue)
|
|
return queue
|
|
|
|
|
|
@dataclass
|
|
class PartnerInstance:
|
|
"""A running partner and its runtime objects."""
|
|
|
|
partner_id: str
|
|
config: PartnerConfig
|
|
started_at: datetime = field(default_factory=datetime.now)
|
|
tasks: list[asyncio.Task] = field(default_factory=list, repr=False)
|
|
runner: PartnerRunner | None = None
|
|
channel_manager: Any = None
|
|
notify_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
|
channel_bindings: dict[str, str] = field(default_factory=dict)
|
|
reload_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
|
last_reload_error: str | None = None
|
|
# In-flight web turns by session_key, decoupled from the socket so a
|
|
# refresh can reattach (see :class:`LiveTurn`).
|
|
live_turns: dict[str, LiveTurn] = field(default_factory=dict, repr=False)
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return any(not t.done() for t in self.tasks)
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
include_secrets: bool = False,
|
|
mask_secrets: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Serialise to a JSON-friendly dict (channel shapes as in TutorBot:
|
|
names-only by default, masked dict for detail views, raw only for the
|
|
explicitly-opt-in edit form)."""
|
|
source_channels = strip_legacy_global_delivery(self.config.channels)
|
|
if include_secrets:
|
|
channels: Any = source_channels
|
|
elif mask_secrets:
|
|
channels = mask_channel_secrets(source_channels)
|
|
else:
|
|
channels = list(source_channels.keys())
|
|
|
|
return {
|
|
"partner_id": self.partner_id,
|
|
"name": self.config.name,
|
|
"description": self.config.description,
|
|
"channels": channels,
|
|
"llm_selection": self.config.llm_selection,
|
|
"backup_llm_selection": self.config.backup_llm_selection,
|
|
"model": self.config.model,
|
|
"language": self.config.language,
|
|
"emoji": self.config.emoji,
|
|
"color": self.config.color,
|
|
"avatar": self.config.avatar,
|
|
"soul_origin": self.config.soul_origin,
|
|
"enabled_tools": self.config.enabled_tools,
|
|
"builtin_tools": self.config.builtin_tools,
|
|
"mcp_tools": self.config.mcp_tools,
|
|
"running": self.running,
|
|
"started_at": self.started_at.isoformat(),
|
|
"last_reload_error": self.last_reload_error,
|
|
}
|
|
|
|
|
|
class PartnerManager:
|
|
"""Manage partner instances running in-process."""
|
|
|
|
def __init__(self) -> None:
|
|
self._partners: dict[str, PartnerInstance] = {}
|
|
self._stores: dict[str, PartnerSessionStore] = {}
|
|
self._migrated_legacy = False
|
|
|
|
# ── Path helpers ──────────────────────────────────────────────
|
|
|
|
@property
|
|
def _partners_dir(self) -> Path:
|
|
return get_data_dir()
|
|
|
|
def _partner_dir(self, partner_id: str) -> Path:
|
|
return self._partners_dir / partner_id
|
|
|
|
def session_store(self, partner_id: str) -> PartnerSessionStore:
|
|
store = self._stores.get(partner_id)
|
|
if store is None:
|
|
store = PartnerSessionStore(get_partner_sessions_dir(partner_id))
|
|
self._stores[partner_id] = store
|
|
return store
|
|
|
|
def _ensure_partner_dirs(self, partner_id: str) -> None:
|
|
get_partner_dir(partner_id)
|
|
ensure_partner_workspace(partner_id)
|
|
get_partner_sessions_dir(partner_id)
|
|
if not read_soul(partner_id).strip():
|
|
write_soul(partner_id, DEFAULT_SOUL)
|
|
|
|
# ── Config persistence ────────────────────────────────────────
|
|
|
|
# Every PartnerConfig field must be listed here so merge_config can update
|
|
# it via the API; a field omitted here is silently un-updatable.
|
|
# auto_start is intentionally absent — lifecycle state, not config.
|
|
_MERGEABLE_FIELDS = (
|
|
"name",
|
|
"description",
|
|
"channels",
|
|
"llm_selection",
|
|
"backup_llm_selection",
|
|
"model",
|
|
"language",
|
|
"emoji",
|
|
"color",
|
|
"avatar",
|
|
"soul_origin",
|
|
"enabled_tools",
|
|
"builtin_tools",
|
|
"mcp_tools",
|
|
)
|
|
|
|
def load_config(self, partner_id: str) -> PartnerConfig | None:
|
|
path = self._partner_dir(partner_id) / "config.yaml"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
return PartnerConfig(
|
|
name=data.get("name", partner_id),
|
|
description=data.get("description", ""),
|
|
channels=strip_legacy_global_delivery(data.get("channels", {}) or {}),
|
|
llm_selection=data.get("llm_selection"),
|
|
backup_llm_selection=data.get("backup_llm_selection"),
|
|
model=data.get("model"),
|
|
language=str(data.get("language", "") or ""),
|
|
emoji=str(data.get("emoji", "") or ""),
|
|
color=str(data.get("color", "") or ""),
|
|
avatar=str(data.get("avatar", "") or ""),
|
|
soul_origin=dict(data.get("soul_origin", {}) or {}),
|
|
enabled_tools=_optional_str_list(data.get("enabled_tools")),
|
|
builtin_tools=_optional_str_list(data.get("builtin_tools")),
|
|
mcp_tools=_optional_str_list(data.get("mcp_tools")),
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to load partner config %s", partner_id)
|
|
return None
|
|
|
|
def save_config(
|
|
self, partner_id: str, config: PartnerConfig, *, auto_start: bool | None = None
|
|
) -> None:
|
|
"""Persist atomically (write-temp + replace).
|
|
|
|
``auto_start`` is the persisted intent to launch on backend boot,
|
|
managed separately from config fields (same contract as TutorBot —
|
|
``None`` preserves the on-disk value; a brand-new partner defaults to
|
|
``True``)."""
|
|
partner_dir = self._partner_dir(partner_id)
|
|
path = partner_dir / "config.yaml"
|
|
if auto_start is None:
|
|
auto_start = self._load_auto_start(partner_id, default=False) if path.exists() else True
|
|
partner_dir.mkdir(parents=True, exist_ok=True)
|
|
data: dict[str, Any] = {
|
|
"name": config.name,
|
|
"description": config.description,
|
|
"channels": strip_legacy_global_delivery(config.channels),
|
|
"language": config.language,
|
|
"emoji": config.emoji,
|
|
"color": config.color,
|
|
"avatar": config.avatar,
|
|
"soul_origin": config.soul_origin,
|
|
"auto_start": auto_start,
|
|
}
|
|
if config.llm_selection:
|
|
data["llm_selection"] = config.llm_selection
|
|
if config.backup_llm_selection:
|
|
data["backup_llm_selection"] = config.backup_llm_selection
|
|
if config.model:
|
|
data["model"] = config.model
|
|
if config.enabled_tools is not None:
|
|
data["enabled_tools"] = list(config.enabled_tools)
|
|
if config.builtin_tools is not None:
|
|
data["builtin_tools"] = list(config.builtin_tools)
|
|
if config.mcp_tools is not None:
|
|
data["mcp_tools"] = list(config.mcp_tools)
|
|
|
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
tmp_path.write_text(yaml.dump(data, allow_unicode=True), encoding="utf-8")
|
|
tmp_path.replace(path)
|
|
|
|
def _load_auto_start(self, partner_id: str, *, default: bool = False) -> bool:
|
|
path = self._partner_dir(partner_id) / "config.yaml"
|
|
if not path.exists():
|
|
return default
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
return bool(data.get("auto_start", default))
|
|
except Exception:
|
|
logger.exception("Failed to load auto_start flag for partner '%s'", partner_id)
|
|
return default
|
|
|
|
def auto_start_enabled(self, partner_id: str, *, default: bool = False) -> bool:
|
|
"""Return whether this partner is allowed to start without an explicit user action."""
|
|
return self._load_auto_start(partner_id, default=default)
|
|
|
|
def merge_config(self, partner_id: str, overrides: dict[str, Any]) -> PartnerConfig:
|
|
"""Overlay non-``None`` overrides onto the on-disk config.
|
|
|
|
Empty strings / dicts are intentional clears and DO override —
|
|
callers must use ``None`` to mean "leave as-is"."""
|
|
existing = self.load_config(partner_id)
|
|
base = existing or PartnerConfig(name=partner_id)
|
|
for key in self._MERGEABLE_FIELDS:
|
|
if key in overrides and overrides[key] is not None:
|
|
setattr(base, key, overrides[key])
|
|
return base
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────
|
|
|
|
async def start_partner(
|
|
self, partner_id: str, config: PartnerConfig | None = None
|
|
) -> PartnerInstance:
|
|
if partner_id in self._partners and self._partners[partner_id].running:
|
|
return self._partners[partner_id]
|
|
|
|
self._ensure_partner_dirs(partner_id)
|
|
|
|
if config is None:
|
|
config = self.load_config(partner_id)
|
|
if config is None:
|
|
config = PartnerConfig(name=partner_id)
|
|
self.save_config(partner_id, config)
|
|
|
|
from deeptutor.partners.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
store = self.session_store(partner_id)
|
|
runner = PartnerRunner(partner_id, config, bus, store, save_config=self.save_config)
|
|
|
|
try:
|
|
channel_manager = self._build_channel_manager(config, bus, partner_id=partner_id)
|
|
except Exception:
|
|
logger.exception("Failed to initialise channels for partner '%s'", partner_id)
|
|
channel_manager = None
|
|
|
|
instance = PartnerInstance(
|
|
partner_id=partner_id,
|
|
config=config,
|
|
runner=runner,
|
|
channel_manager=channel_manager,
|
|
)
|
|
|
|
runner_task = asyncio.create_task(runner.run(), name=f"partner:{partner_id}:runner")
|
|
router_task = asyncio.create_task(
|
|
self._outbound_router(partner_id, bus, instance),
|
|
name=f"partner:{partner_id}:router",
|
|
)
|
|
instance.tasks.extend([runner_task, router_task])
|
|
|
|
if channel_manager:
|
|
for ch_name, ch in channel_manager.channels.items():
|
|
instance.tasks.append(
|
|
asyncio.create_task(ch.start(), name=f"partner:{partner_id}:ch:{ch_name}")
|
|
)
|
|
|
|
self._partners[partner_id] = instance
|
|
# Omit auto_start so save_config preserves the persisted intent: a
|
|
# lazy start (web chat) of an auto_start:false partner must not
|
|
# silently re-enable auto-start.
|
|
self.save_config(partner_id, config)
|
|
logger.info("Partner '%s' started", partner_id)
|
|
return instance
|
|
|
|
async def _outbound_router(self, partner_id: str, bus: Any, instance: PartnerInstance) -> None:
|
|
"""Route outbound messages to channels, web notify_queue, and EventBus."""
|
|
try:
|
|
from deeptutor.events.event_bus import Event, EventType, get_event_bus
|
|
from deeptutor.partners.bus.events import OutboundMessage as _OMsg
|
|
|
|
event_bus = get_event_bus()
|
|
while True:
|
|
msg: _OMsg = await bus.consume_outbound()
|
|
is_progress = bool(msg.metadata and msg.metadata.get("_progress"))
|
|
|
|
if instance.channel_manager:
|
|
channel = instance.channel_manager.get_channel(msg.channel)
|
|
if channel:
|
|
try:
|
|
await channel.send(msg)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to send to channel %s for partner %s",
|
|
msg.channel,
|
|
partner_id,
|
|
)
|
|
if not is_progress and msg.chat_id:
|
|
instance.channel_bindings[msg.channel] = msg.chat_id
|
|
|
|
if not is_progress:
|
|
await instance.notify_queue.put(msg.content or "")
|
|
await event_bus.publish(
|
|
Event(
|
|
type=EventType.CAPABILITY_COMPLETE,
|
|
task_id=f"partner:{partner_id}:{msg.channel}:{msg.chat_id}",
|
|
user_input="",
|
|
agent_output=msg.content or "",
|
|
metadata={
|
|
"source": "partner",
|
|
"partner_id": partner_id,
|
|
"channel": msg.channel,
|
|
"chat_id": msg.chat_id,
|
|
},
|
|
)
|
|
)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
logger.exception("Outbound router failed for partner %s", partner_id)
|
|
|
|
async def stop_partner(self, partner_id: str, *, preserve_auto_start: bool = False) -> bool:
|
|
"""Stop a running partner.
|
|
|
|
Manual stops disable future auto-starts; process shutdown preserves
|
|
the persisted intent so host restarts bring the same partners back."""
|
|
instance = self._partners.get(partner_id)
|
|
if not instance:
|
|
return False
|
|
auto_start = (
|
|
self._load_auto_start(partner_id, default=True) if preserve_auto_start else False
|
|
)
|
|
|
|
for task in instance.tasks:
|
|
if not task.done():
|
|
task.cancel()
|
|
for task in instance.tasks:
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
|
|
if instance.channel_manager:
|
|
try:
|
|
await instance.channel_manager.stop_all()
|
|
except Exception:
|
|
logger.exception("Error stopping channels for partner '%s'", partner_id)
|
|
|
|
self.save_config(partner_id, instance.config, auto_start=auto_start)
|
|
del self._partners[partner_id]
|
|
logger.info("Partner '%s' stopped (auto_start=%s)", partner_id, auto_start)
|
|
return True
|
|
|
|
def _build_channel_manager(
|
|
self,
|
|
config: PartnerConfig,
|
|
bus: Any,
|
|
*,
|
|
partner_id: str,
|
|
) -> Any | None:
|
|
"""Construct a ``ChannelManager`` from ``config.channels`` or ``None``."""
|
|
if not config.channels:
|
|
return None
|
|
from deeptutor.partners.channels.manager import ChannelManager
|
|
from deeptutor.partners.config.schema import ChannelsConfig
|
|
|
|
channels_config = ChannelsConfig(**config.channels)
|
|
manager = ChannelManager(channels_config, bus)
|
|
if not manager.channels:
|
|
logger.info("No channels matched config for partner '%s'", partner_id)
|
|
return None
|
|
logger.info(
|
|
"Channels enabled for partner '%s': %s",
|
|
partner_id,
|
|
list(manager.channels.keys()),
|
|
)
|
|
return manager
|
|
|
|
async def reload_channels(self, partner_id: str) -> None:
|
|
"""Restart channel listeners from ``instance.config.channels``.
|
|
|
|
Cancels only ``partner:{id}:ch:*`` tasks; runner and router keep
|
|
running. Serialised per partner via ``instance.reload_lock``. On
|
|
failure the listeners stay down and the error is recorded on
|
|
``instance.last_reload_error`` for the UI."""
|
|
instance = self._partners.get(partner_id)
|
|
if not instance or not instance.running:
|
|
return
|
|
|
|
async with instance.reload_lock:
|
|
await self._teardown_channel_listeners(instance, partner_id)
|
|
|
|
try:
|
|
channel_manager = self._build_channel_manager(
|
|
instance.config,
|
|
instance.runner.bus if instance.runner else None,
|
|
partner_id=partner_id,
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("Failed to reload channels for partner '%s'", partner_id)
|
|
instance.channel_manager = None
|
|
instance.last_reload_error = f"{type(exc).__name__}: {exc}"
|
|
raise
|
|
|
|
instance.channel_manager = channel_manager
|
|
instance.last_reload_error = None
|
|
if channel_manager:
|
|
for ch_name, ch in channel_manager.channels.items():
|
|
instance.tasks.append(
|
|
asyncio.create_task(ch.start(), name=f"partner:{partner_id}:ch:{ch_name}")
|
|
)
|
|
logger.info(
|
|
"Reloaded channels for partner '%s': %s",
|
|
partner_id,
|
|
list(channel_manager.channels.keys()),
|
|
)
|
|
|
|
async def _teardown_channel_listeners(
|
|
self,
|
|
instance: PartnerInstance,
|
|
partner_id: str,
|
|
) -> None:
|
|
ch_prefix = f"partner:{partner_id}:ch:"
|
|
to_remove = [t for t in instance.tasks if (t.get_name() or "").startswith(ch_prefix)]
|
|
for t in to_remove:
|
|
if not t.done():
|
|
t.cancel()
|
|
for t in to_remove:
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(t), timeout=5.0)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
instance.tasks = [t for t in instance.tasks if t not in to_remove]
|
|
|
|
if instance.channel_manager:
|
|
try:
|
|
await instance.channel_manager.stop_all()
|
|
except Exception:
|
|
logger.exception(
|
|
"Error stopping channels before reload for partner '%s'", partner_id
|
|
)
|
|
|
|
instance.channel_manager = None
|
|
instance.channel_bindings.clear()
|
|
|
|
# ── Listing & discovery ───────────────────────────────────────
|
|
|
|
def _discover_partner_ids(self) -> set[str]:
|
|
self._migrate_legacy_tutorbot()
|
|
ids: set[str] = set()
|
|
if not self._partners_dir.exists():
|
|
return ids
|
|
for entry in self._partners_dir.iterdir():
|
|
if entry.name in _RESERVED_NAMES or entry.name.startswith((".", "_")):
|
|
continue
|
|
if entry.is_dir() and (entry / "config.yaml").exists():
|
|
ids.add(entry.name)
|
|
return ids
|
|
|
|
def list_partners(self) -> list[dict[str, Any]]:
|
|
"""All known partners (running + configured on disk); channels keys-only."""
|
|
result: dict[str, dict[str, Any]] = {}
|
|
|
|
for inst in self._partners.values():
|
|
result[inst.partner_id] = inst.to_dict()
|
|
|
|
for pid in self._discover_partner_ids():
|
|
if pid in result:
|
|
continue
|
|
cfg = self.load_config(pid)
|
|
result[pid] = {
|
|
"partner_id": pid,
|
|
"name": cfg.name if cfg else pid,
|
|
"description": cfg.description if cfg else "",
|
|
"channels": list(cfg.channels.keys()) if cfg else [],
|
|
"llm_selection": cfg.llm_selection if cfg else None,
|
|
"backup_llm_selection": cfg.backup_llm_selection if cfg else None,
|
|
"model": cfg.model if cfg else None,
|
|
"language": cfg.language if cfg else "",
|
|
"emoji": cfg.emoji if cfg else "",
|
|
"color": cfg.color if cfg else "",
|
|
"avatar": cfg.avatar if cfg else "",
|
|
"soul_origin": cfg.soul_origin if cfg else {},
|
|
"enabled_tools": cfg.enabled_tools if cfg else None,
|
|
"builtin_tools": cfg.builtin_tools if cfg else None,
|
|
"mcp_tools": cfg.mcp_tools if cfg else None,
|
|
"running": False,
|
|
"started_at": None,
|
|
}
|
|
|
|
return list(result.values())
|
|
|
|
def get_partner(self, partner_id: str) -> PartnerInstance | None:
|
|
return self._partners.get(partner_id)
|
|
|
|
def partner_exists(self, partner_id: str) -> bool:
|
|
return (self._partner_dir(partner_id) / "config.yaml").exists()
|
|
|
|
@staticmethod
|
|
def web_session_key(
|
|
partner_id: str, *, chat_id: str = "web", session_id: str | None = None
|
|
) -> str:
|
|
normalized_session = str(session_id or "").strip()
|
|
if normalized_session:
|
|
return f"web:{normalized_session}"
|
|
normalized_chat = str(chat_id or "web").strip() or "web"
|
|
if normalized_chat != "web":
|
|
return f"web:{normalized_chat}"
|
|
return f"partner:{partner_id}"
|
|
|
|
def get_history(
|
|
self, partner_id: str, *, session_key: str | None = None, limit: int = 100
|
|
) -> list[dict[str, Any]]:
|
|
store = self.session_store(partner_id)
|
|
if session_key:
|
|
return store.messages(session_key, limit=limit)
|
|
return store.merged_messages(limit=limit)
|
|
|
|
def get_recent_active_partners(self, limit: int = 3) -> list[dict[str, Any]]:
|
|
activity: list[tuple[str, dict[str, Any]]] = []
|
|
for pid in self._discover_partner_ids():
|
|
sessions = self.session_store(pid).list_sessions()
|
|
if not sessions:
|
|
continue
|
|
newest = sessions[0]
|
|
cfg = self.load_config(pid)
|
|
instance = self._partners.get(pid)
|
|
activity.append(
|
|
(
|
|
str(newest.get("updated_at", "")),
|
|
{
|
|
"partner_id": pid,
|
|
"name": cfg.name if cfg else pid,
|
|
"running": instance.running if instance else False,
|
|
"last_message": newest.get("last_message", ""),
|
|
"updated_at": newest.get("updated_at", ""),
|
|
},
|
|
)
|
|
)
|
|
activity.sort(key=lambda item: item[0], reverse=True)
|
|
return [item[1] for item in activity[:limit]]
|
|
|
|
# ── Messaging (web entry point) ───────────────────────────────
|
|
|
|
async def send_message(
|
|
self,
|
|
partner_id: str,
|
|
content: str,
|
|
chat_id: str = "web",
|
|
session_id: str | None = None,
|
|
media: list[str] | None = None,
|
|
on_event: Callable[[Any], Awaitable[None]] | None = None,
|
|
session_key: str | None = None,
|
|
) -> str:
|
|
"""Send a web message to a running partner and return the reply.
|
|
|
|
``session_key``, when given, is used verbatim (the web app drives a
|
|
stable, colon-free key it persists locally); otherwise it is derived
|
|
from ``session_id`` / ``chat_id`` via :meth:`web_session_key`.
|
|
"""
|
|
instance = self._partners.get(partner_id)
|
|
if not instance or not instance.running or not instance.runner:
|
|
raise RuntimeError(f"Partner '{partner_id}' is not running")
|
|
|
|
from deeptutor.partners.bus.events import InboundMessage
|
|
|
|
resolved_key = (
|
|
str(session_key).strip()
|
|
if session_key
|
|
else self.web_session_key(partner_id, chat_id=chat_id, session_id=session_id)
|
|
)
|
|
msg = InboundMessage(
|
|
channel="web",
|
|
sender_id="web",
|
|
chat_id=session_id or chat_id,
|
|
content=content,
|
|
media=media or [],
|
|
session_key_override=resolved_key,
|
|
)
|
|
return await instance.runner.process_message(msg, on_event=on_event)
|
|
|
|
# ── Live web turns (refresh-survivable streaming) ─────────────
|
|
|
|
def start_web_turn(
|
|
self,
|
|
partner_id: str,
|
|
session_key: str,
|
|
content: str,
|
|
media: list[str] | None = None,
|
|
) -> "LiveTurn":
|
|
"""Run a web turn as an instance-owned task and return its LiveTurn.
|
|
|
|
If a turn is already in flight for this session, returns it (one at a
|
|
time per session). The turn keeps running even if every socket detaches.
|
|
"""
|
|
instance = self._partners.get(partner_id)
|
|
if not instance or not instance.running or not instance.runner:
|
|
raise RuntimeError(f"Partner '{partner_id}' is not running")
|
|
existing = instance.live_turns.get(session_key)
|
|
if existing is not None and not existing.done:
|
|
return existing
|
|
turn = LiveTurn(user_content=content)
|
|
instance.live_turns[session_key] = turn
|
|
turn.task = asyncio.create_task(
|
|
self._drive_web_turn(partner_id, session_key, content, media or [], turn),
|
|
name=f"partner:{partner_id}:webturn",
|
|
)
|
|
return turn
|
|
|
|
def subscribe_web_turn(self, partner_id: str, session_key: str) -> "LiveTurn | None":
|
|
"""The in-flight turn for a session, or None (completed turns are read
|
|
back from history instead)."""
|
|
instance = self._partners.get(partner_id)
|
|
if not instance:
|
|
return None
|
|
turn = instance.live_turns.get(session_key)
|
|
return turn if turn is not None and not turn.done else None
|
|
|
|
def stop_web_turn(self, partner_id: str, session_key: str) -> bool:
|
|
instance = self._partners.get(partner_id)
|
|
if not instance:
|
|
return False
|
|
turn = instance.live_turns.get(session_key)
|
|
if turn is None or turn.done or turn.task is None or turn.task.done():
|
|
return False
|
|
turn.task.cancel()
|
|
return True
|
|
|
|
async def _drive_web_turn(
|
|
self,
|
|
partner_id: str,
|
|
session_key: str,
|
|
content: str,
|
|
media: list[str],
|
|
turn: "LiveTurn",
|
|
) -> None:
|
|
async def on_event(event: Any) -> None:
|
|
turn.emit({"type": "stream_event", "event": event.to_dict()})
|
|
|
|
try:
|
|
final = await self.send_message(
|
|
partner_id,
|
|
content,
|
|
session_key=session_key,
|
|
media=media,
|
|
on_event=on_event,
|
|
)
|
|
turn.finish([{"type": "content", "content": final}, {"type": "done"}])
|
|
except asyncio.CancelledError:
|
|
turn.finish([{"type": "stopped"}])
|
|
raise
|
|
except RuntimeError as exc:
|
|
turn.finish([{"type": "error", "content": str(exc)}, {"type": "done"}])
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("Web turn crashed for partner '%s'", partner_id)
|
|
turn.finish(
|
|
[
|
|
{"type": "error", "content": f"Internal error ({type(exc).__name__})"},
|
|
{"type": "done"},
|
|
]
|
|
)
|
|
|
|
# ── Session lifecycle (web management) ────────────────────────
|
|
|
|
def resume_session(self, partner_id: str, session_key: str) -> dict[str, Any] | None:
|
|
"""Clear a session's archived flag so it becomes current again."""
|
|
store = self.session_store(partner_id)
|
|
store.set_archived(session_key, False)
|
|
for session in store.list_sessions():
|
|
if session["session_key"] == store._stem(session_key):
|
|
return session
|
|
return None
|
|
|
|
def delete_session(self, partner_id: str, session_key: str) -> bool:
|
|
return self.session_store(partner_id).delete_session(session_key)
|
|
|
|
def branch_session(
|
|
self, partner_id: str, source_key: str, new_key: str
|
|
) -> dict[str, Any] | None:
|
|
return self.session_store(partner_id).branch(source_key, new_key)
|
|
|
|
def archive_session(self, partner_id: str, session_key: str) -> None:
|
|
self.session_store(partner_id).set_archived(session_key, True)
|
|
|
|
# ── Boot / shutdown ───────────────────────────────────────────
|
|
|
|
async def auto_start_partners(self) -> None:
|
|
for pid in self._discover_partner_ids():
|
|
if pid in self._partners and self._partners[pid].running:
|
|
continue
|
|
try:
|
|
if not self._load_auto_start(pid, default=False):
|
|
continue
|
|
config = self.load_config(pid)
|
|
if config is None:
|
|
continue
|
|
await self.start_partner(pid, config)
|
|
logger.info("Auto-started partner '%s'", pid)
|
|
except Exception:
|
|
logger.exception("Failed to auto-start partner '%s'", pid)
|
|
|
|
async def stop_all(self, *, preserve_auto_start: bool = True) -> None:
|
|
for partner_id in list(self._partners.keys()):
|
|
await self.stop_partner(partner_id, preserve_auto_start=preserve_auto_start)
|
|
|
|
async def destroy_partner(self, partner_id: str) -> bool:
|
|
await self.stop_partner(partner_id, preserve_auto_start=False)
|
|
self._stores.pop(partner_id, None)
|
|
try:
|
|
from deeptutor.services.cron import get_cron_service
|
|
|
|
get_cron_service().remove_owner_jobs(f"partner:{partner_id}")
|
|
except Exception:
|
|
logger.warning("Failed to clear cron jobs for '%s'", partner_id, exc_info=True)
|
|
partner_dir = self._partner_dir(partner_id)
|
|
if not partner_dir.exists():
|
|
return False
|
|
shutil.rmtree(partner_dir)
|
|
logger.info("Partner '%s' destroyed (data deleted)", partner_id)
|
|
return True
|
|
|
|
# ── Legacy TutorBot migration ─────────────────────────────────
|
|
|
|
def _migrate_legacy_tutorbot(self) -> None:
|
|
"""One-shot migration of ``data/tutorbot/`` bots into partners.
|
|
|
|
Channel configs (with secrets), LLM selection, and souls survive;
|
|
the old engine's bootstrap files do not. ``persona`` becomes the
|
|
partner's SOUL.md. The legacy tree is left in place untouched so the
|
|
migration is non-destructive."""
|
|
if self._migrated_legacy:
|
|
return
|
|
self._migrated_legacy = True
|
|
|
|
legacy_root = self._partners_dir.parent / "tutorbot"
|
|
if not legacy_root.is_dir():
|
|
return
|
|
|
|
legacy_souls = legacy_root / "_souls.yaml"
|
|
new_souls = self._souls_file
|
|
if legacy_souls.is_file() and not new_souls.exists():
|
|
try:
|
|
self._partners_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(legacy_souls, new_souls)
|
|
except OSError:
|
|
logger.exception("Failed to migrate TutorBot souls library")
|
|
|
|
for entry in legacy_root.iterdir():
|
|
if not entry.is_dir() or entry.name in _RESERVED_NAMES | {"bots"}:
|
|
continue
|
|
legacy_config = entry / "config.yaml"
|
|
if not legacy_config.is_file():
|
|
continue
|
|
partner_id = entry.name
|
|
if (self._partner_dir(partner_id) / "config.yaml").exists():
|
|
continue
|
|
try:
|
|
data = yaml.safe_load(legacy_config.read_text(encoding="utf-8")) or {}
|
|
config = PartnerConfig(
|
|
name=data.get("name", partner_id),
|
|
description=data.get("description", ""),
|
|
channels=data.get("channels", {}) or {},
|
|
llm_selection=data.get("llm_selection"),
|
|
model=data.get("model"),
|
|
soul_origin={"type": "tutorbot", "id": partner_id},
|
|
)
|
|
self._ensure_partner_dirs(partner_id)
|
|
persona = str(data.get("persona", "") or "").strip()
|
|
if persona:
|
|
write_soul(partner_id, persona)
|
|
self.save_config(partner_id, config, auto_start=bool(data.get("auto_start", False)))
|
|
legacy_sessions = entry / "workspace" / "sessions"
|
|
if legacy_sessions.is_dir():
|
|
dst = get_partner_sessions_dir(partner_id)
|
|
for jsonl in legacy_sessions.glob("*.jsonl"):
|
|
target = dst / jsonl.name
|
|
if not target.exists():
|
|
shutil.copy2(jsonl, target)
|
|
logger.info("Migrated TutorBot '%s' to partner", partner_id)
|
|
except Exception:
|
|
logger.exception("Failed to migrate TutorBot '%s'", partner_id)
|
|
|
|
# ── Soul template library ─────────────────────────────────────
|
|
|
|
@property
|
|
def _souls_file(self) -> Path:
|
|
return self._partners_dir / "_souls.yaml"
|
|
|
|
def _load_souls(self) -> list[dict[str, str]]:
|
|
path = self._souls_file
|
|
if not path.exists():
|
|
self._seed_default_souls()
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
souls = data if isinstance(data, list) else []
|
|
except Exception:
|
|
return []
|
|
refreshed = _refresh_stale_default_souls(souls)
|
|
if refreshed is not None:
|
|
self._save_souls(refreshed)
|
|
return refreshed
|
|
return souls
|
|
|
|
def _save_souls(self, souls: list[dict[str, str]]) -> None:
|
|
self._partners_dir.mkdir(parents=True, exist_ok=True)
|
|
self._souls_file.write_text(
|
|
yaml.dump(souls, allow_unicode=True, default_flow_style=False),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _seed_default_souls(self) -> None:
|
|
self._save_souls([dict(entry) for entry in DEFAULT_SOUL_TEMPLATES])
|
|
|
|
def list_souls(self) -> list[dict[str, str]]:
|
|
return self._load_souls()
|
|
|
|
def get_soul(self, soul_id: str) -> dict[str, str] | None:
|
|
for s in self._load_souls():
|
|
if s.get("id") == soul_id:
|
|
return s
|
|
return None
|
|
|
|
def create_soul(self, soul_id: str, name: str, content: str) -> dict[str, str]:
|
|
souls = self._load_souls()
|
|
entry = {"id": soul_id, "name": name, "content": content}
|
|
souls.append(entry)
|
|
self._save_souls(souls)
|
|
return entry
|
|
|
|
def update_soul(
|
|
self, soul_id: str, name: str | None, content: str | None
|
|
) -> dict[str, str] | None:
|
|
souls = self._load_souls()
|
|
for s in souls:
|
|
if s.get("id") == soul_id:
|
|
if name is not None:
|
|
s["name"] = name
|
|
if content is not None:
|
|
s["content"] = content
|
|
self._save_souls(souls)
|
|
return s
|
|
return None
|
|
|
|
def delete_soul(self, soul_id: str) -> bool:
|
|
souls = self._load_souls()
|
|
new = [s for s in souls if s.get("id") != soul_id]
|
|
if len(new) == len(souls):
|
|
return False
|
|
self._save_souls(new)
|
|
return True
|
|
|
|
|
|
# ── Default soul templates ────────────────────────────────────────
|
|
#
|
|
# Seeded into a fresh library, and used to refresh untouched copies of the
|
|
# old seeds (see ``_refresh_stale_default_souls``). Partners live in IM
|
|
# channels, so every template bakes in a chat-sized voice.
|
|
|
|
DEFAULT_SOUL_TEMPLATES: tuple[dict[str, str], ...] = (
|
|
{
|
|
"id": "companion",
|
|
"name": "Learning Companion",
|
|
"content": """# Soul
|
|
|
|
I am a learning companion — a steady, curious presence that helps people
|
|
actually understand things, not just collect answers.
|
|
|
|
## Voice
|
|
|
|
- Warm, direct, and concrete; no lecture-hall tone
|
|
- Chat-sized messages: one idea at a time, short paragraphs
|
|
- Plain words first, precise terms second — I define jargon the moment I use it
|
|
|
|
## How I help
|
|
|
|
- Start from what you already know, then build exactly one step up
|
|
- Prefer a worked example over an abstract explanation
|
|
- Check understanding with one small question, never a quiz barrage
|
|
- When I don't know, I say so and find out — accuracy beats fluency
|
|
|
|
## Boundaries
|
|
|
|
- On homework-style questions I guide before I reveal: hint, stronger hint, then the step
|
|
- I keep your pace — encouragement, never condescension
|
|
""",
|
|
},
|
|
{
|
|
"id": "math-tutor",
|
|
"name": "Math Tutor",
|
|
"content": """# Soul
|
|
|
|
I am a math tutor. What I'm after is the moment it clicks — not the answer itself.
|
|
|
|
## Voice
|
|
|
|
- Calm and unhurried; math anxiety is real and I never feed it
|
|
- Small steps, one per message, written to be read in a chat window
|
|
- Notation when it sharpens the idea, words when they're clearer
|
|
|
|
## Method
|
|
|
|
- Diagnose first: where exactly does your reasoning break?
|
|
- Socratic by default — a nudge, then a stronger hint, then the step itself
|
|
- Always ask "why does this rule work", not just "apply this rule"
|
|
- After we solve it, one quick variation to confirm it actually clicked
|
|
|
|
## Habits
|
|
|
|
- Estimate before computing; sanity-check after
|
|
- A wrong answer is information, not failure — we find the good idea inside it
|
|
""",
|
|
},
|
|
{
|
|
"id": "coding-assistant",
|
|
"name": "Coding Assistant",
|
|
"content": """# Soul
|
|
|
|
I am a coding assistant — a pragmatic pair programmer living in your chat.
|
|
|
|
## Voice
|
|
|
|
- Precise and to the point: the snippet that matters, not a wall of code
|
|
- Code speaks first, prose explains why
|
|
- Honest about uncertainty and trade-offs — no hand-waving
|
|
|
|
## Craft
|
|
|
|
- Read before writing: respect the existing style and constraints
|
|
- Smallest change that solves the problem; name the trade-off when there is one
|
|
- Errors and edge cases are part of the answer, not an afterthought
|
|
- Every fix ships with a way to verify it — a test, a command, an expected output
|
|
|
|
## Boundaries
|
|
|
|
- I don't guess APIs; when unsure I say so and show how to check
|
|
""",
|
|
},
|
|
{
|
|
"id": "research-helper",
|
|
"name": "Research Helper",
|
|
"content": """# Soul
|
|
|
|
I am a research helper. I turn vague questions into answerable ones, and
|
|
answers into something you can actually trust.
|
|
|
|
## Voice
|
|
|
|
- Neutral and curious; I argue with evidence, not adjectives
|
|
- Structured replies sized for chat: the claim, the evidence, what's still open
|
|
|
|
## Method
|
|
|
|
- Decompose broad questions into specific sub-questions before searching
|
|
- Separate established findings, active debate, and speculation — and label which is which
|
|
- Prefer primary sources; cite what I rely on so you can check it
|
|
- Steelman the opposing reading before settling a question
|
|
|
|
## Honesty
|
|
|
|
- "The evidence is thin here" is a finding, not an apology
|
|
- I flag my own uncertainty instead of smoothing it over
|
|
""",
|
|
},
|
|
{
|
|
"id": "language-tutor",
|
|
"name": "Language Tutor",
|
|
"content": """# Soul
|
|
|
|
I am a language tutor. You learn a language by using it — so this chat is
|
|
the classroom.
|
|
|
|
## Voice
|
|
|
|
- Friendly and a little playful; mistakes are welcome here
|
|
- I match your level: simple words for beginners, nuance as you grow
|
|
|
|
## Method
|
|
|
|
- Converse first, correct second: I respond to what you said, then gently fix how you said it
|
|
- One correction at a time, with a one-line why
|
|
- Phrases in context, never isolated word lists
|
|
- I recycle yesterday's vocabulary into today's conversation
|
|
|
|
## Habits
|
|
|
|
- I celebrate attempts at structures we haven't covered yet
|
|
- Ask me "how do I say X" and you get the natural phrasing, not the literal one
|
|
""",
|
|
},
|
|
)
|
|
|
|
# Verbatim texts of retired seeds (including the TutorBot-era variants).
|
|
# A library entry that still matches one of these — or that sits on a seed
|
|
# id and still mentions TutorBot — is an untouched old seed: safe to swap
|
|
# for the current template without losing any user writing.
|
|
_TUTORBOT_SEED = (
|
|
"# Soul\n\nI am TutorBot, a personal learning companion.\n\n"
|
|
"## Personality\n\n- Helpful and friendly\n- Clear, encouraging, and patient\n"
|
|
"- Adapts explanations to the user's level\n\n"
|
|
"## Values\n\n- Accuracy over speed\n- User privacy and safety\n- Transparency in actions"
|
|
)
|
|
_SUPERSEDED_SOUL_CONTENTS = frozenset(
|
|
{
|
|
_TUTORBOT_SEED,
|
|
_TUTORBOT_SEED.replace("I am TutorBot,", "I am"),
|
|
(
|
|
"# Soul\n\nI am a math tutor specializing in clear, step-by-step problem solving.\n\n"
|
|
"## Personality\n\n- Patient and methodical\n- Encourages showing work\n"
|
|
"- Celebrates progress on hard problems\n\n"
|
|
"## Teaching Style\n\n- Break complex problems into small steps\n"
|
|
"- Use visual representations when possible\n- Always verify final answers"
|
|
),
|
|
(
|
|
"# Soul\n\nI am a coding assistant focused on helping developers write better software.\n\n"
|
|
"## Personality\n\n- Precise and detail-oriented\n"
|
|
"- Pragmatic — working code over perfect code\n- Explains trade-offs clearly\n\n"
|
|
"## Approach\n\n- Read before writing; understand context first\n"
|
|
"- Suggest tests alongside implementations\n- Prefer standard patterns over clever tricks"
|
|
),
|
|
(
|
|
"# Soul\n\nI am a research assistant helping users explore academic topics in depth.\n\n"
|
|
"## Personality\n\n- Curious and thorough\n"
|
|
"- Balanced — presents multiple perspectives\n- Cites sources when possible\n\n"
|
|
"## Approach\n\n- Decompose broad questions into focused sub-questions\n"
|
|
"- Distinguish established facts from open questions\n- Suggest further reading"
|
|
),
|
|
(
|
|
"# Soul\n\nI am a language learning companion helping users practice and improve.\n\n"
|
|
"## Personality\n\n- Encouraging and patient\n"
|
|
"- Adapts difficulty to learner level\n- Makes learning fun with examples\n\n"
|
|
"## Teaching Style\n\n- Correct mistakes gently with explanations\n"
|
|
"- Use contextual examples over abstract rules\n- Encourage speaking/writing practice"
|
|
),
|
|
}
|
|
)
|
|
# TutorBot-era libraries shipped the default under these ids.
|
|
_LEGACY_SOUL_ID_ALIASES = {"default-tutorbot": "companion", "default": "companion"}
|
|
|
|
|
|
def _is_stale_seed(entry: dict[str, str]) -> bool:
|
|
content = str(entry.get("content") or "")
|
|
return content.strip() in {c.strip() for c in _SUPERSEDED_SOUL_CONTENTS} or (
|
|
"tutorbot" in content.lower()
|
|
)
|
|
|
|
|
|
def _refresh_stale_default_souls(
|
|
souls: list[dict[str, str]],
|
|
) -> list[dict[str, str]] | None:
|
|
"""Upgrade untouched old-seed entries in place; ``None`` if nothing changed.
|
|
|
|
Only entries on known seed ids (or their TutorBot-era aliases) are
|
|
touched, and only when their content is provably an old seed — user-
|
|
authored and user-edited souls pass through verbatim.
|
|
"""
|
|
defaults = {e["id"]: e for e in DEFAULT_SOUL_TEMPLATES}
|
|
present_ids = {str(e.get("id") or "") for e in souls}
|
|
out: list[dict[str, str]] = []
|
|
changed = False
|
|
for entry in souls:
|
|
sid = str(entry.get("id") or "")
|
|
canonical = _LEGACY_SOUL_ID_ALIASES.get(sid, sid)
|
|
if canonical not in defaults or not _is_stale_seed(entry):
|
|
out.append(entry)
|
|
continue
|
|
changed = True
|
|
if canonical != sid and canonical in present_ids:
|
|
continue # canonical entry exists separately; drop the legacy alias
|
|
if any(str(e.get("id")) == canonical for e in out):
|
|
continue # a second alias already collapsed into this id
|
|
out.append(dict(defaults[canonical]))
|
|
present_ids.add(canonical)
|
|
return out if changed else None
|
|
|
|
|
|
_manager: PartnerManager | None = None
|
|
|
|
|
|
def get_partner_manager() -> PartnerManager:
|
|
global _manager
|
|
if _manager is None:
|
|
_manager = PartnerManager()
|
|
return _manager
|