Replace standalone Telegram bot with full CodeAnywhere framework fork. BetterBot shares all framework code and customizes only: - instance.py: BetterBot identity, system prompt, feature flags - tools/site_editing/: list_files, read_file, write_file with auto git push - .env: model defaults and site directory paths - compose/: Docker setup with betterlifesg + memoraiz mounts - deploy script: RackNerd with Infisical secrets
147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
"""Background task manager — spawns long-running Copilot SDK sessions outside the request cycle."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Awaitable, Callable
|
|
|
|
from config import settings
|
|
from copilot_runtime import copilot, stream_session
|
|
from llm_costs import extract_usage_and_cost
|
|
from model_selection import ModelSelection, build_provider_config, resolve_selection
|
|
from ux import extract_final_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BACKGROUND_TIMEOUT = 600 # 10 minutes
|
|
|
|
|
|
@dataclass
|
|
class BackgroundTask:
|
|
task_id: str
|
|
description: str
|
|
thread_id: str
|
|
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
result: str | None = None
|
|
error: str | None = None
|
|
usage: dict | None = None
|
|
_asyncio_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
|
|
|
@property
|
|
def done(self) -> bool:
|
|
return self._asyncio_task is not None and self._asyncio_task.done()
|
|
|
|
@property
|
|
def elapsed_seconds(self) -> float:
|
|
return (datetime.now(timezone.utc) - self.started_at).total_seconds()
|
|
|
|
|
|
class BackgroundTaskManager:
|
|
"""Tracks background agent tasks per thread. One active task per thread."""
|
|
|
|
def __init__(self) -> None:
|
|
self._tasks: dict[str, BackgroundTask] = {}
|
|
self._thread_tasks: dict[str, list[str]] = {}
|
|
self._counter = 0
|
|
|
|
def _next_id(self) -> str:
|
|
self._counter += 1
|
|
return f"bg-{self._counter}"
|
|
|
|
def start(
|
|
self,
|
|
*,
|
|
thread_id: str,
|
|
description: str,
|
|
selection: ModelSelection,
|
|
system_message: str,
|
|
on_complete: Callable[["BackgroundTask"], Awaitable[None]],
|
|
) -> BackgroundTask:
|
|
task_id = self._next_id()
|
|
task = BackgroundTask(task_id=task_id, description=description, thread_id=thread_id)
|
|
|
|
# Background agents use a dedicated model/provider (not the parent session's provider)
|
|
bg_selection = resolve_selection(model=settings.BACKGROUND_MODEL)
|
|
|
|
bg_system = (
|
|
system_message.rstrip() + "\n\nYou are running as a background agent. "
|
|
"Complete the task fully and return your findings. Be thorough."
|
|
)
|
|
|
|
async def _run() -> None:
|
|
try:
|
|
|
|
async def _collect() -> str:
|
|
events: list = []
|
|
async for ev in stream_session(
|
|
copilot,
|
|
model=bg_selection.model,
|
|
provider_config=build_provider_config(bg_selection),
|
|
system_message=bg_system,
|
|
prompt=description,
|
|
):
|
|
events.append(ev)
|
|
# Extract usage before final text
|
|
task.usage = extract_usage_and_cost(bg_selection.model, bg_selection.provider, events)
|
|
return extract_final_text(events) or "Task completed but produced no output."
|
|
|
|
task.result = await asyncio.wait_for(_collect(), timeout=BACKGROUND_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
task.error = f"Timed out after {BACKGROUND_TIMEOUT}s"
|
|
except Exception as exc:
|
|
task.error = str(exc)
|
|
logger.exception("Background task %s failed", task_id)
|
|
finally:
|
|
try:
|
|
await on_complete(task)
|
|
except Exception:
|
|
logger.exception("Callback failed for background task %s", task_id)
|
|
|
|
task._asyncio_task = asyncio.create_task(_run())
|
|
self._tasks[task_id] = task
|
|
self._thread_tasks.setdefault(thread_id, []).append(task_id)
|
|
return task
|
|
|
|
def get_active(self, thread_id: str) -> BackgroundTask | None:
|
|
for task_id in reversed(self._thread_tasks.get(thread_id, [])):
|
|
task = self._tasks.get(task_id)
|
|
if task and not task.done:
|
|
return task
|
|
return None
|
|
|
|
def get_latest(self, thread_id: str) -> BackgroundTask | None:
|
|
ids = self._thread_tasks.get(thread_id, [])
|
|
if not ids:
|
|
return None
|
|
return self._tasks.get(ids[-1])
|
|
|
|
def context_summary(self, thread_id: str) -> str | None:
|
|
"""Return background-agent context to inject into the system message, or None."""
|
|
active = self.get_active(thread_id)
|
|
if active:
|
|
return (
|
|
f"A background agent is currently running ({active.elapsed_seconds:.0f}s elapsed).\n"
|
|
f"Task: {active.description}\n"
|
|
"Its results will be posted to the chat when done."
|
|
)
|
|
latest = self.get_latest(thread_id)
|
|
if latest is None:
|
|
return None
|
|
if latest.error:
|
|
return f"The last background agent failed: {latest.error}"
|
|
if latest.result:
|
|
snippet = latest.result[:2000] + ("..." if len(latest.result) > 2000 else "")
|
|
return f"The last background agent completed.\nTask: {latest.description}\nResult:\n{snippet}"
|
|
return None
|
|
|
|
def format_status(self, thread_id: str) -> str:
|
|
active = self.get_active(thread_id)
|
|
if active:
|
|
return f"A background agent is running ({active.elapsed_seconds:.0f}s elapsed).\nTask: {active.description}"
|
|
latest = self.get_latest(thread_id)
|
|
if latest is None:
|
|
return "No background agent has run in this thread."
|
|
if latest.error:
|
|
return f"Last background agent failed: {latest.error}"
|
|
return f"Last background agent completed.\nTask: {latest.description}"
|