mirror of
https://github.com/furyhawk/deepresearch.git
synced 2026-07-21 02:06:48 +00:00
1900 lines
68 KiB
Python
1900 lines
68 KiB
Python
"""DeepResearch — full-featured autonomous research agent with web search via MCP.
|
|
|
|
Features:
|
|
- MCP tools for web search (Tavily, Brave) and URL reading (Jina)
|
|
- Research-specific system prompt with TODO-based planning
|
|
- Shell execution (sandboxed in Docker) with human-in-the-loop approval
|
|
- Subagents (code-reviewer, general-purpose, dynamic agent factory)
|
|
- Plan mode (ask_user question flow)
|
|
- Skills (research-methodology, report-writing, quick-reference)
|
|
- Hooks (audit logger, safety gate)
|
|
- Capabilities (AuditCapability, PermissionCapability)
|
|
- Image support (multimodal attachments)
|
|
- Docker sandbox per user for file operations
|
|
- WebSocket streaming for real-time updates
|
|
- Checkpointing (rewind/fork)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env from the deepresearch app directory so it works regardless
|
|
# of the current working directory the server is started from.
|
|
_env_path = Path(__file__).resolve().parent.parent.parent / ".env"
|
|
load_dotenv(dotenv_path=_env_path, override=True)
|
|
|
|
import urllib.parse # noqa: E402
|
|
|
|
import httpx # noqa: E402
|
|
import markdown as md_lib # noqa: E402
|
|
import uvicorn # noqa: E402
|
|
from fastapi import ( # noqa: E402
|
|
FastAPI,
|
|
File,
|
|
HTTPException,
|
|
Query,
|
|
UploadFile,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
|
from fastapi.responses import HTMLResponse, JSONResponse, Response # noqa: E402
|
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
|
from pydantic import TypeAdapter # noqa: E402
|
|
from pydantic_ai import ( # noqa: E402
|
|
BinaryContent,
|
|
FinalResultEvent,
|
|
PartDeltaEvent,
|
|
PartStartEvent,
|
|
TextPartDelta,
|
|
ThinkingPartDelta,
|
|
ToolCallPartDelta,
|
|
)
|
|
from pydantic_ai._agent_graph import End, UserPromptNode # noqa: E402
|
|
from pydantic_ai.agent import Agent # noqa: E402
|
|
from pydantic_ai.messages import ( # noqa: E402
|
|
FunctionToolCallEvent,
|
|
FunctionToolResultEvent,
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
TextPart,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
from pydantic_ai.tools import ( # noqa: E402
|
|
DeferredToolRequests,
|
|
DeferredToolResults,
|
|
ToolApproved,
|
|
ToolDenied,
|
|
)
|
|
from pydantic_ai.toolsets import AbstractToolset # noqa: E402
|
|
from subagents_pydantic_ai.types import TaskStatus # noqa: E402
|
|
|
|
from pydantic_deep import ( # noqa: E402
|
|
DeepAgentDeps,
|
|
InMemoryCheckpointStore,
|
|
RewindRequested,
|
|
SessionManager,
|
|
fork_from_checkpoint,
|
|
)
|
|
|
|
from .agent import create_research_agent # noqa: E402
|
|
from .config import ( # noqa: E402
|
|
APP_DIR,
|
|
EXCALIDRAW_CANVAS_URL,
|
|
SKILLS_DIR,
|
|
STATIC_DIR,
|
|
WORKSPACE_DIR,
|
|
WORKSPACES_DIR,
|
|
create_mcp_servers,
|
|
)
|
|
from .middleware import AuditCapability, PermissionCapability # noqa: E402
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s | %(levelname)-8s | %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
for _lib in (
|
|
"chardet",
|
|
"charset_normalizer",
|
|
"multipart",
|
|
"httpcore",
|
|
"httpx",
|
|
"docker",
|
|
"urllib3",
|
|
):
|
|
logging.getLogger(_lib).setLevel(logging.WARNING)
|
|
|
|
WORKSPACE_DIR.mkdir(exist_ok=True)
|
|
WORKSPACES_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
_TEXT_EXTS = {
|
|
"txt",
|
|
"md",
|
|
"csv",
|
|
"tsv",
|
|
"json",
|
|
"jsonl",
|
|
"py",
|
|
"js",
|
|
"ts",
|
|
"jsx",
|
|
"tsx",
|
|
"html",
|
|
"htm",
|
|
"css",
|
|
"xml",
|
|
"yaml",
|
|
"yml",
|
|
"toml",
|
|
"ini",
|
|
"cfg",
|
|
"conf",
|
|
"sh",
|
|
"bash",
|
|
"zsh",
|
|
"sql",
|
|
"r",
|
|
"rb",
|
|
"go",
|
|
"rs",
|
|
"java",
|
|
"c",
|
|
"cpp",
|
|
"h",
|
|
"hpp",
|
|
"cs",
|
|
"swift",
|
|
"kt",
|
|
"lua",
|
|
"log",
|
|
"env",
|
|
"gitignore",
|
|
"dockerfile",
|
|
}
|
|
|
|
_PREVIEW_LINES = 15
|
|
_PREVIEW_CHARS = 800
|
|
|
|
_CONTENT_TYPES: dict[str, str] = {
|
|
"html": "text/html",
|
|
"htm": "text/html",
|
|
"css": "text/css",
|
|
"js": "application/javascript",
|
|
"json": "application/json",
|
|
"svg": "image/svg+xml",
|
|
"png": "image/png",
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"gif": "image/gif",
|
|
"webp": "image/webp",
|
|
"pdf": "application/pdf",
|
|
}
|
|
|
|
|
|
def _fmt_size(n: int) -> str:
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if n < 1024:
|
|
return f"{n:.1f} {unit}" if n != int(n) else f"{n} {unit}"
|
|
n /= 1024 # type: ignore[assignment]
|
|
return f"{n:.1f} TB"
|
|
|
|
|
|
def _build_file_summary(name: str, path: str, data: bytes, media_type: str) -> str:
|
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
size = len(data)
|
|
summary = f"- **{name}** ({_fmt_size(size)}) — path: `{path}`"
|
|
|
|
if ext in _TEXT_EXTS or media_type.startswith("text/"):
|
|
try:
|
|
text = data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
try:
|
|
text = data.decode("latin-1")
|
|
except Exception:
|
|
return summary + " — binary file, use `read_file` to inspect"
|
|
|
|
lines = text.splitlines()
|
|
char_count = len(text)
|
|
line_count = len(lines)
|
|
summary += f" — {line_count} lines, {char_count} chars"
|
|
|
|
preview_lines = lines[:_PREVIEW_LINES]
|
|
preview = "\n".join(preview_lines)
|
|
if len(preview) > _PREVIEW_CHARS:
|
|
preview = preview[:_PREVIEW_CHARS] + "..."
|
|
truncated = line_count > _PREVIEW_LINES or len(preview) >= _PREVIEW_CHARS
|
|
|
|
summary += f"\n ```\n{preview}\n ```"
|
|
if truncated:
|
|
remaining = line_count - _PREVIEW_LINES
|
|
summary += (
|
|
f"\n *(preview — {remaining} more lines,"
|
|
f' use `read_file("{path}")` for full content)*'
|
|
)
|
|
else:
|
|
summary += f" — binary ({media_type}), use `read_file` to inspect"
|
|
|
|
return summary
|
|
|
|
|
|
audit_cap = AuditCapability()
|
|
permission_cap = PermissionCapability()
|
|
|
|
|
|
@dataclass
|
|
class UserSession:
|
|
"""Per-user session state."""
|
|
|
|
session_id: str
|
|
deps: DeepAgentDeps
|
|
message_history: list[ModelMessage] = field(default_factory=list)
|
|
pending_approval_state: dict[str, Any] = field(default_factory=dict)
|
|
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
running_task: asyncio.Task[None] | None = field(default=None)
|
|
latest_todos: list[dict[str, Any]] = field(default_factory=list)
|
|
pending_questions: dict[str, asyncio.Future[str]] = field(default_factory=dict)
|
|
checkpoint_store: InMemoryCheckpointStore = field(default_factory=InMemoryCheckpointStore)
|
|
# Background task push notification tracking
|
|
_notified_tasks: set[str] = field(default_factory=set)
|
|
_injected_tasks: set[str] = field(default_factory=set)
|
|
|
|
|
|
def _log_event(session: UserSession | None, event: dict[str, Any]) -> None:
|
|
"""Append a WebSocket event to the session's JSONL event log."""
|
|
if session is None:
|
|
return
|
|
events_dir = WORKSPACES_DIR / session.session_id
|
|
events_dir.mkdir(parents=True, exist_ok=True)
|
|
events_file = events_dir / "events.jsonl"
|
|
event_with_ts = {**event, "_ts": datetime.now(timezone.utc).isoformat()}
|
|
try:
|
|
with open(events_file, "a") as f:
|
|
f.write(json.dumps(event_with_ts, default=str) + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _save_session_meta(session: UserSession, title: str | None = None) -> None:
|
|
"""Write or update session metadata to meta.json."""
|
|
meta_dir = WORKSPACES_DIR / session.session_id
|
|
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
meta_file = meta_dir / "meta.json"
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
if meta_file.exists():
|
|
try:
|
|
existing = json.loads(meta_file.read_text())
|
|
except Exception:
|
|
existing = {}
|
|
existing["updated_at"] = now
|
|
existing["message_count"] = len(session.message_history)
|
|
if title:
|
|
existing["title"] = title
|
|
meta_file.write_text(json.dumps(existing))
|
|
else:
|
|
meta = {
|
|
"session_id": session.session_id,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"title": title or "New Session",
|
|
"message_count": len(session.message_history),
|
|
}
|
|
meta_file.write_text(json.dumps(meta))
|
|
|
|
|
|
def _persist_history(session: UserSession) -> None:
|
|
"""Serialize message_history to disk for agent continuity on reload."""
|
|
|
|
history_dir = WORKSPACES_DIR / session.session_id
|
|
history_dir.mkdir(parents=True, exist_ok=True)
|
|
history_file = history_dir / "history.json"
|
|
try:
|
|
ta = TypeAdapter(list[ModelMessage])
|
|
history_file.write_bytes(ta.dump_json(session.message_history))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to persist history: {e}")
|
|
|
|
|
|
def _restore_history(session_id: str) -> list[ModelMessage] | None:
|
|
"""Restore message_history from disk if available."""
|
|
|
|
history_file = WORKSPACES_DIR / session_id / "history.json"
|
|
if not history_file.exists():
|
|
return None
|
|
try:
|
|
ta = TypeAdapter(list[ModelMessage])
|
|
return ta.validate_json(history_file.read_bytes())
|
|
except Exception as e:
|
|
logger.warning(f"Failed to restore history for {session_id}: {e}")
|
|
return None
|
|
|
|
|
|
def _extract_title(user_prompt: str | list[Any]) -> str:
|
|
"""Extract a session title from the first user message."""
|
|
if isinstance(user_prompt, str):
|
|
text = user_prompt
|
|
elif isinstance(user_prompt, list):
|
|
text = next((p for p in user_prompt if isinstance(p, str)), "")
|
|
else:
|
|
text = str(user_prompt)
|
|
text = text.strip().split("\n")[0]
|
|
return text[:60] if text else "New Session"
|
|
|
|
|
|
def _get_task_manager() -> Any | None:
|
|
"""Find the TaskManager from the agent's subagent toolset."""
|
|
if agent is None:
|
|
return None
|
|
for ts in agent.toolsets:
|
|
tm = getattr(ts, "task_manager", None)
|
|
if tm is not None:
|
|
return tm
|
|
return None
|
|
|
|
|
|
async def _monitor_background_tasks(websocket: WebSocket, session: UserSession) -> None:
|
|
"""Poll TaskManager for newly completed/failed tasks and push notifications via WebSocket."""
|
|
|
|
task_manager = _get_task_manager()
|
|
if task_manager is None:
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
for task_id, handle in list(task_manager.handles.items()):
|
|
if task_id in session._notified_tasks:
|
|
continue
|
|
if handle.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
|
|
session._notified_tasks.add(task_id)
|
|
duration = None
|
|
if handle.started_at and handle.completed_at:
|
|
duration = (handle.completed_at - handle.started_at).total_seconds()
|
|
result_preview = None
|
|
if handle.result:
|
|
result_preview = handle.result[:2000]
|
|
try:
|
|
await websocket.send_json(
|
|
{
|
|
"type": "background_task_completed",
|
|
"task_id": task_id,
|
|
"subagent_name": handle.subagent_name,
|
|
"status": handle.status.value,
|
|
"description": handle.description,
|
|
"result_preview": result_preview,
|
|
"error": handle.error,
|
|
"duration_seconds": duration,
|
|
}
|
|
)
|
|
except Exception:
|
|
return # WebSocket closed
|
|
except asyncio.CancelledError:
|
|
return
|
|
|
|
|
|
def _collect_completed_task_results(session: UserSession) -> str | None:
|
|
"""Collect results from completed background tasks that haven't been injected yet."""
|
|
|
|
task_manager = _get_task_manager()
|
|
if task_manager is None:
|
|
return None
|
|
|
|
parts: list[str] = []
|
|
for task_id, handle in list(task_manager.handles.items()):
|
|
if task_id in session._injected_tasks:
|
|
continue
|
|
if handle.status == TaskStatus.COMPLETED and handle.result:
|
|
session._injected_tasks.add(task_id)
|
|
duration = ""
|
|
if handle.started_at and handle.completed_at:
|
|
secs = (handle.completed_at - handle.started_at).total_seconds()
|
|
duration = f" ({secs:.1f}s)"
|
|
parts.append(
|
|
f"- **{handle.subagent_name}**{duration}: {handle.description}\n"
|
|
f" Result: {handle.result[:1000]}"
|
|
)
|
|
elif handle.status == TaskStatus.FAILED and handle.error:
|
|
session._injected_tasks.add(task_id)
|
|
parts.append(
|
|
f"- **{handle.subagent_name}** (FAILED): {handle.description}\n"
|
|
f" Error: {handle.error[:500]}"
|
|
)
|
|
|
|
if not parts:
|
|
return None
|
|
|
|
return (
|
|
"**Note**: The following background tasks have completed since your last message:\n\n"
|
|
+ "\n".join(parts)
|
|
)
|
|
|
|
|
|
def create_ask_user_callback(websocket: WebSocket, session: UserSession) -> Any:
|
|
"""Create an ask_user callback that sends questions via WebSocket.
|
|
|
|
When the planner subagent calls ask_user(), this callback:
|
|
1. Sends the question + options to the frontend via WebSocket
|
|
2. Waits for the user's response (via asyncio.Future)
|
|
3. Returns the answer to the agent
|
|
"""
|
|
|
|
async def callback(question: str, options: list[dict[str, str]]) -> str:
|
|
question_id = str(uuid.uuid4())
|
|
future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
|
session.pending_questions[question_id] = future
|
|
|
|
logger.info(f"ASK_USER: {question} (options: {len(options)})")
|
|
|
|
await websocket.send_json(
|
|
{
|
|
"type": "ask_user_question",
|
|
"question_id": question_id,
|
|
"question": question,
|
|
"options": options,
|
|
}
|
|
)
|
|
|
|
answer = await future
|
|
logger.info(f"ASK_USER answer: {answer}")
|
|
return answer
|
|
|
|
return callback
|
|
|
|
|
|
agent: Agent[DeepAgentDeps, str] | None = None
|
|
session_manager: SessionManager | None = None
|
|
user_sessions: dict[str, UserSession] = {}
|
|
|
|
|
|
_DEEP_MD_PATH = APP_DIR / "workspace" / "DEEP.md"
|
|
_MEMORY_MD_PATH = APP_DIR / "workspace" / "MEMORY.md"
|
|
|
|
|
|
# Excalidraw canvas isolation — save/restore per session
|
|
|
|
_current_canvas_session: str | None = None
|
|
|
|
|
|
async def _save_canvas(session_id: str) -> None:
|
|
"""Save current canvas elements to disk for the given session."""
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
resp = await client.get(f"{EXCALIDRAW_CANVAS_URL}/api/elements")
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
elements = data.get("elements", [])
|
|
canvas_file = WORKSPACES_DIR / session_id / "canvas.json"
|
|
canvas_file.parent.mkdir(parents=True, exist_ok=True)
|
|
canvas_file.write_text(json.dumps(elements))
|
|
logger.info(f"Canvas SAVE: {len(elements)} elements for session {session_id}")
|
|
else:
|
|
logger.warning(f"Canvas SAVE failed: GET /api/elements returned {resp.status_code}")
|
|
except Exception as e:
|
|
logger.warning(f"Canvas SAVE error for session {session_id}: {e}")
|
|
|
|
|
|
async def _load_canvas(session_id: str) -> None:
|
|
"""Clear canvas and load saved elements for the given session."""
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
# Clear canvas
|
|
clear_resp = await client.delete(f"{EXCALIDRAW_CANVAS_URL}/api/elements/clear")
|
|
logger.info(f"Canvas CLEAR: status={clear_resp.status_code}")
|
|
|
|
# Load saved elements
|
|
canvas_file = WORKSPACES_DIR / session_id / "canvas.json"
|
|
if canvas_file.exists():
|
|
elements = json.loads(canvas_file.read_text())
|
|
if elements:
|
|
load_resp = await client.post(
|
|
f"{EXCALIDRAW_CANVAS_URL}/api/elements/batch",
|
|
json={"elements": elements},
|
|
)
|
|
logger.info(
|
|
f"Canvas LOAD: {len(elements)} elements for session {session_id} "
|
|
f"(status={load_resp.status_code})"
|
|
)
|
|
else:
|
|
logger.info(f"Canvas LOAD: no elements saved for session {session_id}")
|
|
else:
|
|
logger.info(f"Canvas LOAD: no canvas.json for session {session_id} (fresh session)")
|
|
except Exception as e:
|
|
logger.warning(f"Canvas LOAD error for session {session_id}: {e}")
|
|
|
|
|
|
async def _switch_canvas_session(session_id: str) -> None:
|
|
"""Switch canvas to a different session (save old, load new)."""
|
|
global _current_canvas_session
|
|
logger.info(f"Canvas SWITCH: {_current_canvas_session!r} -> {session_id!r}")
|
|
if _current_canvas_session == session_id:
|
|
logger.info("Canvas SWITCH: same session, skipping")
|
|
return
|
|
if _current_canvas_session is not None:
|
|
await _save_canvas(_current_canvas_session)
|
|
await _load_canvas(session_id)
|
|
_current_canvas_session = session_id
|
|
logger.info(f"Canvas SWITCH: done, now on session {session_id}")
|
|
|
|
|
|
async def get_or_create_session(session_id: str) -> UserSession:
|
|
"""Get existing session or create a new one with isolated Docker container."""
|
|
global session_manager, user_sessions
|
|
|
|
if session_id in user_sessions:
|
|
return user_sessions[session_id]
|
|
|
|
assert session_manager is not None
|
|
sandbox = await session_manager.get_or_create(session_id)
|
|
|
|
# Seed workspace with context files
|
|
if _DEEP_MD_PATH.exists():
|
|
sandbox.write("/workspace/DEEP.md", _DEEP_MD_PATH.read_text())
|
|
if _MEMORY_MD_PATH.exists():
|
|
sandbox.write("/workspace/MEMORY.md", _MEMORY_MD_PATH.read_text())
|
|
|
|
cp_store = InMemoryCheckpointStore()
|
|
deps = DeepAgentDeps(backend=sandbox, checkpoint_store=cp_store)
|
|
|
|
session = UserSession(session_id=session_id, deps=deps, checkpoint_store=cp_store)
|
|
|
|
# Restore message history from disk if available
|
|
restored = _restore_history(session_id)
|
|
if restored:
|
|
session.message_history = restored
|
|
logger.info(f"Restored {len(restored)} messages for session {session_id}")
|
|
|
|
# Restore todos from meta if available
|
|
meta_file = WORKSPACES_DIR / session_id / "meta.json"
|
|
if meta_file.exists():
|
|
try:
|
|
meta = json.loads(meta_file.read_text())
|
|
session.latest_todos = meta.get("todos", [])
|
|
except Exception:
|
|
pass
|
|
|
|
user_sessions[session_id] = session
|
|
|
|
logger.info(f"Created new session: {session_id}")
|
|
return session
|
|
|
|
|
|
def _get_failed_server_names(
|
|
exc: BaseException,
|
|
servers: list[AbstractToolset],
|
|
) -> set[str]:
|
|
"""Extract MCP server prefixes that likely caused a startup failure.
|
|
|
|
Unwraps ExceptionGroup to get at the actual error message, then tries to
|
|
match against server prefix names and their known URLs.
|
|
|
|
When we can't pinpoint the exact server, return all prefixes so the
|
|
retry removes every MCP server (the app still works without them).
|
|
"""
|
|
all_names = {getattr(s, "prefix", "") for s in servers} - {""}
|
|
# If only one MCP server, it's obviously the culprit
|
|
if len(all_names) == 1:
|
|
return all_names
|
|
|
|
# Unwrap ExceptionGroup to get at the real error message
|
|
def _collect_messages(exc: BaseException) -> list[str]:
|
|
msgs: list[str] = []
|
|
if isinstance(exc, BaseExceptionGroup):
|
|
for sub in exc.exceptions:
|
|
msgs.extend(_collect_messages(sub))
|
|
else:
|
|
msgs.append(str(exc).lower())
|
|
return msgs
|
|
|
|
messages = _collect_messages(exc)
|
|
if not messages:
|
|
messages = [str(exc).lower()]
|
|
|
|
# Known URL patterns for HTTP-based MCP servers — maps URL substring -> prefix
|
|
URL_PATTERNS: dict[str, str] = {
|
|
"r.jina.ai": "jina",
|
|
}
|
|
|
|
matched: set[str] = set()
|
|
for msg in messages:
|
|
for name in all_names:
|
|
if name.lower() in msg:
|
|
matched.add(name)
|
|
# Check URL patterns
|
|
for url_substring, prefix in URL_PATTERNS.items():
|
|
if url_substring in msg and prefix in all_names:
|
|
matched.add(prefix)
|
|
# Check docker/stdio heuristics
|
|
if "docker" in msg or "stdio" in msg or "broken" in msg:
|
|
for s in servers:
|
|
if getattr(s, "prefix", "") == "excalidraw":
|
|
matched.add("excalidraw")
|
|
return matched or all_names
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Initialize agent with MCP servers and session manager."""
|
|
global agent, session_manager
|
|
|
|
mcp_servers = create_mcp_servers()
|
|
agent = create_research_agent(mcp_servers=mcp_servers, middleware=[audit_cap, permission_cap])
|
|
|
|
session_manager = SessionManager(
|
|
default_runtime="python-datascience",
|
|
default_idle_timeout=3600,
|
|
workspace_root=WORKSPACES_DIR,
|
|
)
|
|
session_manager.start_cleanup_loop(interval=300)
|
|
|
|
def _print_banner(servers: list) -> None:
|
|
names = [getattr(s, "prefix", "unknown") for s in servers]
|
|
print("=" * 60)
|
|
print("DeepResearch — Full-Featured Research Agent")
|
|
print("=" * 60)
|
|
print(f" MCP servers : {', '.join(names) or 'none'}")
|
|
print(f" Skills : {SKILLS_DIR}")
|
|
print(f" Workspaces : {WORKSPACES_DIR}")
|
|
print(" Runtime : python-datascience")
|
|
print(" Hooks : audit_logger, safety_gate")
|
|
print(" Capabilities : AuditCapability, PermissionCapability")
|
|
print(" Subagents : code-reviewer, general-purpose + dynamic factory")
|
|
print(" Execute : enabled (human-in-the-loop)")
|
|
print(" Image support : enabled")
|
|
print(" Plan mode : enabled (ask_user)")
|
|
print("=" * 60)
|
|
|
|
_print_banner(mcp_servers)
|
|
|
|
# Start MCP server connections — retry loop that drops failing servers
|
|
remaining = list(mcp_servers)
|
|
while True:
|
|
try:
|
|
async with agent:
|
|
yield
|
|
except Exception as exc:
|
|
failed = _get_failed_server_names(exc, remaining)
|
|
if not failed:
|
|
logger.warning("MCP server startup failed — no servers left to drop, raising error")
|
|
raise
|
|
logger.warning(
|
|
"MCP server startup failed (%s) — retrying without them",
|
|
", ".join(failed),
|
|
)
|
|
remaining = [s for s in remaining if getattr(s, "prefix", "") not in failed]
|
|
agent = create_research_agent(mcp_servers=remaining, middleware=[audit_cap, permission_cap])
|
|
_print_banner(remaining)
|
|
else:
|
|
break
|
|
|
|
# Shutdown
|
|
count = await session_manager.shutdown()
|
|
print(f"Shutdown complete. Stopped {count} sessions.")
|
|
|
|
|
|
app = FastAPI(title="DeepResearch", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
if STATIC_DIR.exists():
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
html_path = STATIC_DIR / "index.html"
|
|
if html_path.exists():
|
|
return HTMLResponse(content=html_path.read_text())
|
|
return HTMLResponse(content="<h1>Frontend not found. Check static/index.html</h1>")
|
|
|
|
|
|
@app.websocket("/ws/chat")
|
|
async def websocket_chat(websocket: WebSocket): # noqa: C901
|
|
"""WebSocket endpoint for streaming chat with the research agent."""
|
|
global agent
|
|
|
|
await websocket.accept()
|
|
|
|
if agent is None:
|
|
await websocket.send_json({"type": "error", "content": "Agent not initialized"})
|
|
return
|
|
|
|
session: UserSession | None = None
|
|
incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
monitor_task: asyncio.Task[None] | None = None
|
|
|
|
async def _reader() -> None:
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
await incoming.put(json.loads(data))
|
|
except WebSocketDisconnect:
|
|
await incoming.put({"__disconnect": True})
|
|
|
|
reader_task = asyncio.create_task(_reader())
|
|
|
|
try:
|
|
while True:
|
|
message_data = await incoming.get()
|
|
|
|
if message_data.get("__disconnect"):
|
|
break
|
|
|
|
# Session management
|
|
session_id = message_data.get("session_id")
|
|
if session is None:
|
|
if not session_id:
|
|
session_id = str(uuid.uuid4())
|
|
await websocket.send_json({"type": "session_created", "session_id": session_id})
|
|
session = await get_or_create_session(session_id)
|
|
|
|
# Monkey-patch send_json to also log events to JSONL
|
|
_original_send = websocket.send_json
|
|
|
|
async def _logging_send(
|
|
data: Any,
|
|
_send: Any = _original_send,
|
|
_sess: Any = session,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
await _send(data, **kwargs)
|
|
_log_event(_sess, data)
|
|
|
|
websocket.send_json = _logging_send # type: ignore[assignment]
|
|
|
|
# Log session_created event
|
|
_log_event(session, {"type": "session_created", "session_id": session_id})
|
|
|
|
# Set up ask_user callback so planner subagent can ask questions
|
|
session.deps.ask_user = create_ask_user_callback(websocket, session)
|
|
# Start background task monitor for push notifications
|
|
monitor_task = asyncio.create_task(_monitor_background_tasks(websocket, session))
|
|
# Switch Excalidraw canvas to this session
|
|
await _switch_canvas_session(session_id)
|
|
# Tell the frontend canvas is ready (iframe can safely load now)
|
|
await websocket.send_json({"type": "canvas_ready", "session_id": session_id})
|
|
logger.info(f"WebSocket connected for session: {session_id}")
|
|
|
|
# Handle question answers (from planner ask_user)
|
|
question_answer = message_data.get("question_answer")
|
|
if question_answer and session:
|
|
qid = question_answer.get("question_id", "")
|
|
answer = question_answer.get("answer", "")
|
|
if qid in session.pending_questions:
|
|
session.pending_questions[qid].set_result(answer)
|
|
del session.pending_questions[qid]
|
|
logger.info(f"Resolved question {qid}: {answer}")
|
|
continue
|
|
|
|
user_message = message_data.get("message", "")
|
|
approval_response = message_data.get("approval")
|
|
cancel_request = message_data.get("cancel")
|
|
attachments = message_data.get("attachments", [])
|
|
|
|
# Handle cancel request
|
|
if cancel_request:
|
|
if session.running_task and not session.running_task.done():
|
|
logger.info(f"Cancelling agent run for session {session.session_id}")
|
|
session.cancel_event.set()
|
|
# Cancel any pending ask_user futures so the agent unblocks
|
|
for _qid, fut in list(session.pending_questions.items()):
|
|
if not fut.done():
|
|
fut.cancel()
|
|
session.pending_questions.clear()
|
|
session.running_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await session.running_task
|
|
session.running_task = None
|
|
await websocket.send_json({"type": "cancelled"})
|
|
await websocket.send_json({"type": "done"})
|
|
continue
|
|
|
|
# Handle approval response
|
|
if approval_response is not None:
|
|
await handle_approval(websocket, session, approval_response)
|
|
continue
|
|
|
|
if not user_message and not attachments:
|
|
continue
|
|
|
|
# Log incoming user message
|
|
_log_event(session, {"type": "user_message", "content": user_message})
|
|
|
|
# Set session title from first user message
|
|
meta_file = WORKSPACES_DIR / session.session_id / "meta.json"
|
|
if meta_file.exists():
|
|
try:
|
|
meta = json.loads(meta_file.read_text())
|
|
if meta.get("title") == "New Session" and user_message:
|
|
_save_session_meta(session, title=_extract_title(user_message))
|
|
except Exception:
|
|
pass
|
|
else:
|
|
_save_session_meta(
|
|
session, title=_extract_title(user_message) if user_message else None
|
|
)
|
|
|
|
# Build the user prompt (multimodal if attachments present)
|
|
user_prompt: str | list[str | BinaryContent] = user_message
|
|
if attachments:
|
|
import base64 as b64
|
|
|
|
prompt_parts: list[str | BinaryContent] = []
|
|
file_summaries: list[str] = []
|
|
|
|
for att in attachments:
|
|
name = att.get("name", "file")
|
|
media_type = att.get("type", "application/octet-stream")
|
|
data = b64.b64decode(att["data"])
|
|
|
|
# Save to container first
|
|
upload_path = session.deps.upload_file(name, data)
|
|
logger.info(f"Attachment saved: {name} ({len(data)} bytes) -> {upload_path}")
|
|
|
|
if media_type.startswith("image/"):
|
|
prompt_parts.append(BinaryContent(data=data, media_type=media_type))
|
|
file_summaries.append(
|
|
f"- **{name}** (image, {_fmt_size(len(data))})"
|
|
f" — path: `{upload_path}` — sent inline for visual analysis"
|
|
)
|
|
else:
|
|
file_summaries.append(
|
|
_build_file_summary(name, upload_path, data, media_type)
|
|
)
|
|
|
|
files_block = "\n".join(file_summaries)
|
|
if user_message:
|
|
text = (
|
|
f"{user_message}\n\n"
|
|
f"**Attached files:**\n{files_block}\n\n"
|
|
f"Use `read_file` to access full file contents if needed."
|
|
)
|
|
else:
|
|
text = (
|
|
f"I've attached the following files:\n{files_block}\n\n"
|
|
f"Use `read_file` to access full contents. "
|
|
f"What would you like to do with them?"
|
|
)
|
|
|
|
prompt_parts.insert(0, text)
|
|
user_prompt = prompt_parts
|
|
|
|
# Cancel any previous run
|
|
if session.running_task and not session.running_task.done():
|
|
session.cancel_event.set()
|
|
session.running_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await session.running_task
|
|
|
|
session.cancel_event.clear()
|
|
session.running_task = asyncio.create_task(
|
|
_run_agent_task(websocket, session, user_prompt)
|
|
)
|
|
|
|
finally:
|
|
reader_task.cancel()
|
|
if monitor_task is not None:
|
|
monitor_task.cancel()
|
|
if session and session.running_task and not session.running_task.done():
|
|
session.running_task.cancel()
|
|
if session:
|
|
# Save canvas state on disconnect
|
|
await _save_canvas(session.session_id)
|
|
logger.info(f"WebSocket disconnected for session: {session.session_id}")
|
|
|
|
|
|
def _save_partial_history(session: UserSession) -> None:
|
|
"""Save user message + partial agent response to history on cancel."""
|
|
user_msg = getattr(session, "_current_user_message", None)
|
|
streamed = getattr(session, "_streamed_text", "")
|
|
|
|
if not user_msg:
|
|
return
|
|
|
|
session.message_history.append(ModelRequest(parts=[UserPromptPart(content=user_msg)]))
|
|
|
|
if streamed:
|
|
session.message_history.append(
|
|
ModelResponse(parts=[TextPart(content=streamed + "\n\n[Response interrupted]")])
|
|
)
|
|
|
|
logger.info(
|
|
f"Saved partial history: user_msg={user_msg[:60]!r}, "
|
|
f"streamed={len(streamed)} chars, "
|
|
f"history now {len(session.message_history)} messages"
|
|
)
|
|
|
|
session._streamed_text = "" # type: ignore[attr-defined]
|
|
session._current_user_message = None # type: ignore[attr-defined]
|
|
|
|
|
|
async def _run_agent_task(
|
|
websocket: WebSocket,
|
|
session: UserSession,
|
|
user_prompt: str | list[str | BinaryContent],
|
|
) -> None:
|
|
"""Wrapper that runs the agent and sends done/error."""
|
|
try:
|
|
await run_agent_with_streaming(websocket, session, user_prompt)
|
|
except asyncio.CancelledError:
|
|
logger.info(f"Agent run cancelled for session {session.session_id}")
|
|
_save_partial_history(session)
|
|
_persist_history(session)
|
|
_save_session_meta(session)
|
|
raise
|
|
except RewindRequested as rw:
|
|
logger.info(f"Rewind requested to checkpoint '{rw.label}' ({rw.checkpoint_id})")
|
|
session.message_history = rw.messages
|
|
_persist_history(session)
|
|
_save_session_meta(session)
|
|
try:
|
|
await websocket.send_json(
|
|
{
|
|
"type": "checkpoint_rewind",
|
|
"checkpoint_id": rw.checkpoint_id,
|
|
"label": rw.label,
|
|
"message_count": len(rw.messages),
|
|
}
|
|
)
|
|
await websocket.send_json({"type": "done"})
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.exception("Error in agent run")
|
|
try:
|
|
await websocket.send_json({"type": "error", "content": str(e)})
|
|
await websocket.send_json({"type": "done"})
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
session.running_task = None
|
|
|
|
|
|
async def run_agent_with_streaming(
|
|
websocket: WebSocket,
|
|
session: UserSession,
|
|
user_prompt: str | list[str | BinaryContent],
|
|
deferred_results: DeferredToolResults | None = None,
|
|
) -> None:
|
|
"""Run agent with streaming and handle DeferredToolRequests."""
|
|
global agent
|
|
|
|
# Extract text portion for logging and cancel recovery
|
|
if isinstance(user_prompt, str):
|
|
text_preview = user_prompt[:100] if user_prompt else "(continuation)"
|
|
else:
|
|
text_parts = [p for p in user_prompt if isinstance(p, str)]
|
|
text_preview = (
|
|
text_parts[0][:100] if text_parts else "(multimodal)"
|
|
) + f" + {sum(1 for p in user_prompt if isinstance(p, BinaryContent))} files"
|
|
|
|
logger.info(f"=== Starting agent run for session {session.session_id} ===")
|
|
logger.info(f"User prompt: {text_preview}")
|
|
logger.info(f"Deferred results: {deferred_results is not None}")
|
|
logger.info(f"Message history length: {len(session.message_history)}")
|
|
|
|
await websocket.send_json({"type": "start"})
|
|
|
|
# Track streamed text for cancel recovery
|
|
session._streamed_text = "" # type: ignore[attr-defined]
|
|
cancel_text = (
|
|
user_prompt
|
|
if isinstance(user_prompt, str)
|
|
else " ".join(p for p in user_prompt if isinstance(p, str))
|
|
)
|
|
session._current_user_message = cancel_text # type: ignore[attr-defined]
|
|
|
|
# Prepend completed background task results to user prompt (Step 5)
|
|
if deferred_results is None and user_prompt:
|
|
task_results_note = _collect_completed_task_results(session)
|
|
if task_results_note:
|
|
if isinstance(user_prompt, str):
|
|
user_prompt = f"{task_results_note}\n\n---\n\n{user_prompt}"
|
|
elif isinstance(user_prompt, list):
|
|
# Prepend to first text part
|
|
for i, part in enumerate(user_prompt):
|
|
if isinstance(part, str):
|
|
user_prompt[i] = f"{task_results_note}\n\n---\n\n{part}"
|
|
break
|
|
|
|
assert agent is not None
|
|
async with agent.iter(
|
|
user_prompt if deferred_results is None else None,
|
|
deps=session.deps,
|
|
message_history=session.message_history,
|
|
deferred_tool_results=deferred_results,
|
|
) as run:
|
|
node_count = 0
|
|
async for node in run:
|
|
node_count += 1
|
|
logger.debug(f"Node {node_count}: {type(node).__name__}")
|
|
await process_node(websocket, node, run, session)
|
|
|
|
result = run.result
|
|
logger.info(f"Agent finished after {node_count} nodes")
|
|
|
|
# Emit latest checkpoint
|
|
if session.checkpoint_store:
|
|
try:
|
|
all_cps = await session.checkpoint_store.list_all()
|
|
if all_cps:
|
|
latest_cp = all_cps[-1]
|
|
await websocket.send_json(
|
|
{
|
|
"type": "checkpoint_saved",
|
|
"checkpoint_id": latest_cp.id,
|
|
"label": latest_cp.label,
|
|
"turn": latest_cp.turn,
|
|
"message_count": latest_cp.message_count,
|
|
"metadata": latest_cp.metadata,
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Check if we got DeferredToolRequests (needs approval)
|
|
if isinstance(result.output, DeferredToolRequests):
|
|
logger.info(f"Got DeferredToolRequests with {len(result.output.approvals)} approvals")
|
|
session.pending_approval_state = {
|
|
"message_history": result.all_messages(),
|
|
"approvals": result.output.approvals,
|
|
}
|
|
|
|
approval_requests = []
|
|
for call in result.output.approvals:
|
|
logger.info(f" Approval needed: {call.tool_name}({call.args})")
|
|
approval_requests.append(
|
|
{
|
|
"tool_call_id": call.tool_call_id,
|
|
"tool_name": call.tool_name,
|
|
"args": call.args if isinstance(call.args, dict) else str(call.args),
|
|
}
|
|
)
|
|
|
|
await websocket.send_json(
|
|
{
|
|
"type": "approval_required",
|
|
"requests": approval_requests,
|
|
}
|
|
)
|
|
return
|
|
|
|
# Update session's message history
|
|
session.message_history = result.all_messages()
|
|
logger.info(f"Updated message history to {len(session.message_history)} messages")
|
|
|
|
# Persist to disk
|
|
_persist_history(session)
|
|
_save_session_meta(session)
|
|
|
|
await websocket.send_json({"type": "response", "content": str(result.output)})
|
|
await websocket.send_json({"type": "done"})
|
|
logger.info("=== Agent run complete ===")
|
|
|
|
|
|
async def handle_approval(
|
|
websocket: WebSocket, session: UserSession, approval_response: dict
|
|
) -> None:
|
|
"""Handle approval response from frontend and continue agent."""
|
|
if not session.pending_approval_state:
|
|
await websocket.send_json({"type": "error", "content": "No pending approval"})
|
|
return
|
|
|
|
approvals: dict[str, ToolApproved | ToolDenied] = {}
|
|
for tool_call_id, approved in approval_response.items():
|
|
if approved:
|
|
approvals[tool_call_id] = ToolApproved()
|
|
else:
|
|
approvals[tool_call_id] = ToolDenied("User denied this tool call.")
|
|
|
|
session.message_history = session.pending_approval_state["message_history"]
|
|
session.pending_approval_state = {}
|
|
|
|
try:
|
|
await run_agent_with_streaming(
|
|
websocket,
|
|
session,
|
|
"",
|
|
deferred_results=DeferredToolResults(approvals=approvals),
|
|
)
|
|
except Exception as e:
|
|
await websocket.send_json({"type": "error", "content": str(e)})
|
|
|
|
|
|
async def _stream_model_request(
|
|
websocket: WebSocket, node: Any, run: Any, session: UserSession
|
|
) -> None:
|
|
"""Stream text chunks from a ModelRequestNode."""
|
|
await websocket.send_json({"type": "status", "content": "Researching..."})
|
|
|
|
current_tool_name: str | None = None
|
|
|
|
async with node.stream(run.ctx) as request_stream:
|
|
final_result_found = False
|
|
|
|
async for event in request_stream:
|
|
if isinstance(event, PartStartEvent):
|
|
if hasattr(event.part, "tool_name"):
|
|
current_tool_name = event.part.tool_name
|
|
current_tool_call_id = getattr(event.part, "tool_call_id", None)
|
|
await websocket.send_json(
|
|
{
|
|
"type": "tool_call_start",
|
|
"tool_name": current_tool_name,
|
|
"tool_call_id": current_tool_call_id,
|
|
}
|
|
)
|
|
elif isinstance(event, PartDeltaEvent):
|
|
await _handle_part_delta(websocket, event, current_tool_name, session)
|
|
elif isinstance(event, FinalResultEvent):
|
|
final_result_found = True
|
|
break
|
|
|
|
if final_result_found:
|
|
previous_text = ""
|
|
async for cumulative_text in request_stream.stream_text():
|
|
delta = cumulative_text[len(previous_text) :]
|
|
if delta:
|
|
await websocket.send_json({"type": "text_delta", "content": delta})
|
|
session._streamed_text += delta # type: ignore[attr-defined]
|
|
previous_text = cumulative_text
|
|
|
|
|
|
async def _handle_part_delta(
|
|
websocket: WebSocket,
|
|
event: PartDeltaEvent,
|
|
current_tool_name: str | None,
|
|
session: UserSession,
|
|
) -> None:
|
|
"""Handle streaming delta events."""
|
|
if isinstance(event.delta, TextPartDelta):
|
|
await websocket.send_json({"type": "text_delta", "content": event.delta.content_delta})
|
|
session._streamed_text += event.delta.content_delta # type: ignore[attr-defined]
|
|
elif isinstance(event.delta, ThinkingPartDelta):
|
|
await websocket.send_json({"type": "thinking_delta", "content": event.delta.content_delta})
|
|
elif isinstance(event.delta, ToolCallPartDelta):
|
|
await websocket.send_json(
|
|
{
|
|
"type": "tool_args_delta",
|
|
"tool_name": current_tool_name,
|
|
"args_delta": event.delta.args_delta,
|
|
}
|
|
)
|
|
|
|
|
|
async def _emit_todos_update(websocket: WebSocket, session: UserSession) -> None:
|
|
"""Emit todos_update WS event and persist to session meta."""
|
|
todos_data = session.latest_todos
|
|
await websocket.send_json({"type": "todos_update", "todos": todos_data})
|
|
# Persist in session meta
|
|
meta_dir = WORKSPACES_DIR / session.session_id
|
|
meta_file = meta_dir / "meta.json"
|
|
if meta_file.exists():
|
|
try:
|
|
meta = json.loads(meta_file.read_text())
|
|
meta["todos"] = todos_data
|
|
meta_file.write_text(json.dumps(meta))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _stream_tool_calls( # noqa: C901
|
|
websocket: WebSocket, node: Any, run: Any, session: UserSession
|
|
) -> None:
|
|
"""Stream tool call events from a CallToolsNode."""
|
|
tool_names_by_id: dict[str, str] = {}
|
|
tool_args_by_id: dict[str, Any] = {}
|
|
|
|
async with node.stream(run.ctx) as handle_stream:
|
|
async for event in handle_stream:
|
|
if isinstance(event, FunctionToolCallEvent):
|
|
tool_name = event.part.tool_name
|
|
tool_args = event.part.args
|
|
tool_call_id = event.part.tool_call_id
|
|
|
|
logger.info(f" TOOL CALL: {tool_name}")
|
|
|
|
if tool_call_id:
|
|
tool_names_by_id[tool_call_id] = tool_name
|
|
tool_args_by_id[tool_call_id] = tool_args
|
|
|
|
await websocket.send_json(
|
|
{
|
|
"type": "tool_start",
|
|
"tool_name": tool_name,
|
|
"args": tool_args if isinstance(tool_args, dict) else str(tool_args),
|
|
}
|
|
)
|
|
|
|
# Send status update for long-running subagent tasks
|
|
if tool_name == "task":
|
|
try:
|
|
args_dict = (
|
|
tool_args if isinstance(tool_args, dict) else json.loads(tool_args)
|
|
)
|
|
sa_type = args_dict.get("subagent_type", "general-purpose")
|
|
await websocket.send_json(
|
|
{"type": "status", "content": f"Running {sa_type} subagent..."}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Live TODO updates (write_todos has full list in args)
|
|
if tool_name == "write_todos":
|
|
try:
|
|
args_dict = (
|
|
tool_args if isinstance(tool_args, dict) else json.loads(tool_args)
|
|
)
|
|
todos_data = args_dict.get("todos", [])
|
|
session.latest_todos = todos_data
|
|
await _emit_todos_update(websocket, session)
|
|
except Exception:
|
|
pass
|
|
|
|
elif isinstance(event, FunctionToolResultEvent):
|
|
tool_call_id = event.tool_call_id
|
|
tool_name = tool_names_by_id.get(tool_call_id, "unknown")
|
|
result_content = event.result.content
|
|
|
|
logger.info(f" TOOL RESULT: {tool_name} -> {str(result_content)[:100]}...")
|
|
|
|
await websocket.send_json(
|
|
{
|
|
"type": "tool_output",
|
|
"tool_name": tool_name,
|
|
"output": str(result_content),
|
|
}
|
|
)
|
|
|
|
# Live audit stats
|
|
stats = audit_cap.get_stats()
|
|
await websocket.send_json(
|
|
{
|
|
"type": "middleware_event",
|
|
"event": "tool_audit",
|
|
"tool_name": tool_name,
|
|
"total_calls": stats.call_count,
|
|
"tools_breakdown": dict(stats.tools_used),
|
|
}
|
|
)
|
|
|
|
# Report file detection — auto-open preview
|
|
if tool_name == "write_file":
|
|
try:
|
|
call_args = tool_args_by_id.get(tool_call_id, {})
|
|
if isinstance(call_args, str):
|
|
call_args = json.loads(call_args)
|
|
written_path = call_args.get("path", "")
|
|
if "report" in written_path.lower():
|
|
await websocket.send_json(
|
|
{"type": "report_updated", "path": written_path}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Live TODO updates for incremental tools
|
|
# (write_todos is handled at call time above; these need
|
|
# the result to confirm success before updating UI)
|
|
result_str = str(result_content)
|
|
if tool_name == "update_todo_status" and "not found" not in result_str:
|
|
try:
|
|
call_args = tool_args_by_id.get(tool_call_id, {})
|
|
if isinstance(call_args, str):
|
|
call_args = json.loads(call_args)
|
|
tid = call_args.get("todo_id", "")
|
|
new_status = call_args.get("status", "")
|
|
for todo in session.latest_todos:
|
|
if todo.get("id") == tid:
|
|
todo["status"] = new_status
|
|
break
|
|
await _emit_todos_update(websocket, session)
|
|
except Exception:
|
|
pass
|
|
|
|
elif tool_name == "add_todo":
|
|
try:
|
|
# Extract ID from result: "Added todo '...' with ID: abc12345"
|
|
id_match = re.search(r"with ID:\s*(\w+)", result_str)
|
|
if id_match:
|
|
call_args = tool_args_by_id.get(tool_call_id, {})
|
|
if isinstance(call_args, str):
|
|
call_args = json.loads(call_args)
|
|
session.latest_todos.append(
|
|
{
|
|
"id": id_match.group(1),
|
|
"content": call_args.get("content", ""),
|
|
"active_form": call_args.get("active_form", ""),
|
|
"status": "pending",
|
|
}
|
|
)
|
|
await _emit_todos_update(websocket, session)
|
|
except Exception:
|
|
pass
|
|
|
|
elif tool_name == "remove_todo" and "not found" not in result_str:
|
|
try:
|
|
call_args = tool_args_by_id.get(tool_call_id, {})
|
|
if isinstance(call_args, str):
|
|
call_args = json.loads(call_args)
|
|
tid = call_args.get("todo_id", "")
|
|
session.latest_todos = [
|
|
t for t in session.latest_todos if t.get("id") != tid
|
|
]
|
|
await _emit_todos_update(websocket, session)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def process_node(websocket: WebSocket, node: Any, run: Any, session: UserSession) -> None:
|
|
"""Process a node and send appropriate WebSocket events."""
|
|
if isinstance(node, UserPromptNode):
|
|
await websocket.send_json({"type": "status", "content": "Processing..."})
|
|
elif Agent.is_model_request_node(node):
|
|
await _stream_model_request(websocket, node, run, session)
|
|
elif Agent.is_call_tools_node(node):
|
|
await _stream_tool_calls(websocket, node, run, session)
|
|
elif isinstance(node, End):
|
|
await websocket.send_json({"type": "status", "content": "Completed!"})
|
|
|
|
|
|
@app.post("/upload")
|
|
async def upload_file(
|
|
file: UploadFile = File(...), # noqa: B008
|
|
session_id: str = Query("", description="Session ID"),
|
|
):
|
|
"""Upload a file to a session's workspace."""
|
|
try:
|
|
if not session_id:
|
|
session_id = str(uuid.uuid4())
|
|
|
|
session = await get_or_create_session(session_id)
|
|
content = await file.read()
|
|
filename = file.filename or "uploaded_file"
|
|
|
|
logger.info(f"Uploading file: {filename} ({len(content)} bytes) to session {session_id}")
|
|
|
|
path = session.deps.upload_file(filename, content)
|
|
logger.info(f"File uploaded to: {path}")
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"status": "success",
|
|
"filename": filename,
|
|
"path": path,
|
|
"size": len(content),
|
|
"session_id": session_id,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
@app.get("/files")
|
|
async def list_files(session_id: str = Query(..., description="Session ID")):
|
|
"""List files in workspace and uploads."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
files: dict[str, list[str]] = {"workspace": [], "uploads": []}
|
|
|
|
# Use 'find' via execute — ls_info has path quoting issues with DockerSandbox
|
|
if hasattr(session.deps.backend, "execute"):
|
|
for key, path in [("workspace", "/workspace"), ("uploads", "/uploads")]:
|
|
try:
|
|
result = session.deps.backend.execute(f"find {path} -type f 2>/dev/null")
|
|
if result.exit_code == 0:
|
|
files[key] = [f for f in result.output.strip().split("\n") if f]
|
|
except Exception:
|
|
pass
|
|
|
|
return JSONResponse(content=files)
|
|
|
|
|
|
@app.get("/files/content/{filepath:path}")
|
|
async def get_file_content(filepath: str, session_id: str = Query(..., description="Session ID")):
|
|
"""Get file content for preview."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
|
|
decoded_path = urllib.parse.unquote(filepath)
|
|
if not decoded_path.startswith("/"):
|
|
decoded_path = "/" + decoded_path
|
|
|
|
try:
|
|
result = session.deps.backend.read(decoded_path)
|
|
if "Error:" in result and len(result) < 200:
|
|
raise HTTPException(status_code=404, detail=f"File not found: {decoded_path}")
|
|
return JSONResponse(content={"content": result, "path": decoded_path})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
@app.get("/files/binary/{filepath:path}")
|
|
async def get_file_binary(filepath: str, session_id: str = Query(..., description="Session ID")):
|
|
"""Get binary file content (images, etc.)."""
|
|
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
|
|
decoded_path = urllib.parse.unquote(filepath)
|
|
if not decoded_path.startswith("/"):
|
|
decoded_path = "/" + decoded_path
|
|
|
|
ext = decoded_path.split(".")[-1].lower()
|
|
content_type = _CONTENT_TYPES.get(ext, "application/octet-stream")
|
|
|
|
try:
|
|
result = session.deps.backend.read_bytes(decoded_path)
|
|
return Response(content=result, media_type=content_type)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
@app.get("/todos")
|
|
async def get_todos(session_id: str = Query(..., description="Session ID")):
|
|
"""Get current todo list."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return JSONResponse(content={"todos": user_sessions[session_id].latest_todos})
|
|
|
|
|
|
@app.get("/checkpoints")
|
|
async def list_checkpoints(session_id: str = Query(..., description="Session ID")):
|
|
"""List all checkpoints for a session."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
all_cps = await session.checkpoint_store.list_all()
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"checkpoints": [
|
|
{
|
|
"id": cp.id,
|
|
"label": cp.label,
|
|
"turn": cp.turn,
|
|
"message_count": cp.message_count,
|
|
"metadata": cp.metadata,
|
|
}
|
|
for cp in all_cps
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/checkpoints/{checkpoint_id}/rewind")
|
|
async def rewind_to_checkpoint(
|
|
checkpoint_id: str,
|
|
session_id: str = Query(..., description="Session ID"),
|
|
):
|
|
"""Rewind a session to a specific checkpoint."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
cp = await session.checkpoint_store.get(checkpoint_id)
|
|
if cp is None:
|
|
raise HTTPException(status_code=404, detail="Checkpoint not found")
|
|
|
|
session.message_history = list(cp.messages)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"status": "rewound",
|
|
"checkpoint_id": cp.id,
|
|
"label": cp.label,
|
|
"message_count": cp.message_count,
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/checkpoints/{checkpoint_id}/fork")
|
|
async def fork_from_checkpoint_endpoint(
|
|
checkpoint_id: str,
|
|
session_id: str = Query(..., description="Source session ID"),
|
|
):
|
|
"""Fork a new session from a checkpoint."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
messages = await fork_from_checkpoint(session.checkpoint_store, checkpoint_id)
|
|
|
|
new_session_id = str(uuid.uuid4())
|
|
new_session = await get_or_create_session(new_session_id)
|
|
new_session.message_history = messages
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"new_session_id": new_session_id,
|
|
"message_count": len(messages),
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/history")
|
|
async def get_history(session_id: str = Query(..., description="Session ID")):
|
|
"""Return conversation history for a session."""
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
rendered: list[dict[str, Any]] = []
|
|
|
|
# Build a map of tool_call_id → tool_name for matching returns to calls
|
|
tool_name_by_id: dict[str, str] = {}
|
|
|
|
for msg in session.message_history:
|
|
if isinstance(msg, ModelResponse):
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolCallPart):
|
|
tool_name_by_id[part.tool_call_id] = part.tool_name
|
|
|
|
for msg in session.message_history:
|
|
if isinstance(msg, ModelRequest):
|
|
for part in msg.parts:
|
|
if isinstance(part, UserPromptPart):
|
|
content = (
|
|
part.content if isinstance(part.content, str) else "(multimodal input)"
|
|
)
|
|
rendered.append({"role": "user", "content": content})
|
|
elif isinstance(part, ToolReturnPart):
|
|
rendered.append(
|
|
{
|
|
"role": "tool_return",
|
|
"tool_name": tool_name_by_id.get(part.tool_call_id, ""),
|
|
"output": part.content
|
|
if isinstance(part.content, str)
|
|
else json.dumps(part.content or ""),
|
|
}
|
|
)
|
|
elif isinstance(msg, ModelResponse):
|
|
# Render tool calls FIRST, then text (matches streaming order)
|
|
tool_calls = [
|
|
{
|
|
"tool_name": p.tool_name,
|
|
"args": p.args if isinstance(p.args, str) else json.dumps(p.args or {}),
|
|
}
|
|
for p in msg.parts
|
|
if isinstance(p, ToolCallPart)
|
|
]
|
|
for tc in tool_calls:
|
|
rendered.append(
|
|
{"role": "tool_call", "tool_name": tc["tool_name"], "args": tc["args"]}
|
|
)
|
|
text_parts = [p.content for p in msg.parts if isinstance(p, TextPart) and p.content]
|
|
if text_parts:
|
|
rendered.append({"role": "assistant", "content": "\n\n".join(text_parts)})
|
|
|
|
return JSONResponse(content={"messages": rendered})
|
|
|
|
|
|
@app.get("/config")
|
|
async def get_config():
|
|
"""Return current agent configuration."""
|
|
mcp_names = []
|
|
if agent:
|
|
for ts in agent.toolsets:
|
|
prefix = getattr(ts, "prefix", None)
|
|
if prefix:
|
|
mcp_names.append(prefix)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"features": {
|
|
"runtime": "python-datascience",
|
|
"hooks": [
|
|
{
|
|
"name": "audit_logger",
|
|
"event": "POST_TOOL_USE",
|
|
"background": True,
|
|
"description": "Logs all tool calls (fire-and-forget)",
|
|
},
|
|
{
|
|
"name": "safety_gate",
|
|
"event": "PRE_TOOL_USE",
|
|
"matcher": "execute",
|
|
"background": False,
|
|
"description": "Blocks dangerous shell commands",
|
|
},
|
|
],
|
|
"middleware": [
|
|
{
|
|
"name": "AuditCapability",
|
|
"type": "tool_stats",
|
|
"description": "Tracks tool usage count, duration, breakdown",
|
|
},
|
|
{
|
|
"name": "PermissionCapability",
|
|
"type": "path_blocking",
|
|
"description": "Blocks access to /etc/passwd, .env, /root/, etc.",
|
|
},
|
|
],
|
|
"mcp_servers": mcp_names,
|
|
"processors": {
|
|
"eviction": {
|
|
"token_limit": 20000,
|
|
"description": "Large outputs -> file reference",
|
|
},
|
|
"sliding_window": {
|
|
"trigger": "50 messages",
|
|
"keep": "30 messages",
|
|
"description": "Trims old conversation history",
|
|
},
|
|
"patch_tool_calls": True,
|
|
},
|
|
"checkpointing": {
|
|
"enabled": True,
|
|
"frequency": "every_turn",
|
|
"max_checkpoints": 50,
|
|
"description": (
|
|
"Auto-saves after every model turn, rewind/fork via Timeline tab"
|
|
),
|
|
},
|
|
"context_files": ["/workspace/DEEP.md", "/workspace/MEMORY.md"],
|
|
"subagents": [
|
|
"code-reviewer",
|
|
"general-purpose",
|
|
"planner (plan mode)",
|
|
"dynamic (via agent factory)",
|
|
],
|
|
"skills": ["research-methodology", "report-writing", "quick-reference"],
|
|
"interrupt_on": {"execute": True, "write_file": False},
|
|
"excalidraw_enabled": os.getenv("EXCALIDRAW_ENABLED", "1") == "1",
|
|
"excalidraw_canvas_url": EXCALIDRAW_CANVAS_URL,
|
|
"tool_stats": dict(audit_cap.get_stats().tools_used),
|
|
"total_tool_calls": audit_cap.get_stats().call_count,
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/reset")
|
|
async def reset(session_id: str = Query(..., description="Session ID")):
|
|
"""Reset a specific session."""
|
|
global session_manager, user_sessions
|
|
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
if session_manager:
|
|
await session_manager.release(session_id)
|
|
|
|
del user_sessions[session_id]
|
|
audit_cap.reset_stats()
|
|
|
|
return JSONResponse(content={"status": "reset complete", "session_id": session_id})
|
|
|
|
|
|
@app.post("/session/new")
|
|
async def create_new_session():
|
|
"""Create a new session."""
|
|
session_id = str(uuid.uuid4())
|
|
session = await get_or_create_session(session_id)
|
|
return JSONResponse(content={"session_id": session.session_id, "status": "created"})
|
|
|
|
|
|
@app.get("/sessions")
|
|
async def list_sessions():
|
|
"""List all persisted sessions, sorted by most recent."""
|
|
sessions_list = []
|
|
if WORKSPACES_DIR.exists():
|
|
for d in WORKSPACES_DIR.iterdir():
|
|
if not d.is_dir():
|
|
continue
|
|
meta_file = d / "meta.json"
|
|
if meta_file.exists():
|
|
try:
|
|
meta = json.loads(meta_file.read_text())
|
|
sessions_list.append(meta)
|
|
except Exception:
|
|
pass
|
|
|
|
sessions_list.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
|
|
return JSONResponse(content={"sessions": sessions_list})
|
|
|
|
|
|
@app.get("/sessions/{session_id}/events")
|
|
async def get_session_events(session_id: str):
|
|
"""Return all logged WebSocket events for replay."""
|
|
events_file = WORKSPACES_DIR / session_id / "events.jsonl"
|
|
if not events_file.exists():
|
|
raise HTTPException(status_code=404, detail="No events found for this session")
|
|
|
|
events = []
|
|
try:
|
|
with open(events_file) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
events.append(json.loads(line))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to read events: {e}") from e
|
|
|
|
return JSONResponse(content={"events": events})
|
|
|
|
|
|
@app.delete("/sessions/{session_id}")
|
|
async def delete_session(session_id: str):
|
|
"""Delete a session and its data."""
|
|
global session_manager, user_sessions
|
|
|
|
# Release container if active
|
|
if session_id in user_sessions:
|
|
if session_manager:
|
|
await session_manager.release(session_id)
|
|
del user_sessions[session_id]
|
|
|
|
# Remove files
|
|
session_dir = WORKSPACES_DIR / session_id
|
|
if session_dir.exists():
|
|
shutil.rmtree(session_dir)
|
|
return JSONResponse(content={"status": "deleted", "session_id": session_id})
|
|
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "agent_ready": agent is not None, "session_count": len(user_sessions)}
|
|
|
|
|
|
# Export endpoint (Markdown, HTML, PDF)
|
|
|
|
|
|
@app.get("/export/{fmt}")
|
|
async def export_report(
|
|
fmt: str,
|
|
session_id: str = Query(..., description="Session ID"),
|
|
filepath: str = Query("/workspace/report.md", description="Report file path"),
|
|
):
|
|
"""Export a report file in various formats (md, html, pdf)."""
|
|
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
|
|
# Read and strip line numbers from backend
|
|
try:
|
|
raw = session.deps.backend.read(filepath)
|
|
if isinstance(raw, bytes):
|
|
raw = raw.decode("utf-8")
|
|
content = "\n".join(
|
|
line.split("\t", 1)[1]
|
|
if "\t" in line and line.split("\t")[0].strip().isdigit()
|
|
else line
|
|
for line in raw.split("\n")
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=404, detail=f"Report not found: {e}") from e
|
|
|
|
if fmt in ("md", "markdown"):
|
|
return Response(
|
|
content=content,
|
|
media_type="text/markdown",
|
|
headers={"Content-Disposition": "attachment; filename=report.md"},
|
|
)
|
|
|
|
def _md_to_html(md_content: str) -> str:
|
|
_css = (
|
|
"body{font-family:system-ui;max-width:800px;margin:2rem auto;padding:0 1rem;"
|
|
"line-height:1.6;}table{border-collapse:collapse;width:100%;}th,td{border:1px solid "
|
|
"#ddd;padding:8px;}pre{background:#f5f5f5;padding:1rem;overflow-x:auto;border-radius:"
|
|
"4px;}code{background:#f5f5f5;padding:2px 4px;border-radius:3px;}"
|
|
)
|
|
body = md_lib.markdown(md_content, extensions=["tables", "fenced_code"])
|
|
return (
|
|
f"<!DOCTYPE html>\n<html><head><meta charset='utf-8'>"
|
|
f"<title>Research Report</title>\n<style>{_css}</style>\n"
|
|
f"</head><body>{body}</body></html>"
|
|
)
|
|
|
|
if fmt == "html":
|
|
try:
|
|
return Response(
|
|
content=_md_to_html(content),
|
|
media_type="text/html",
|
|
headers={"Content-Disposition": "attachment; filename=report.html"},
|
|
)
|
|
except ImportError as exc:
|
|
raise HTTPException(
|
|
status_code=501,
|
|
detail=("HTML export requires 'markdown' package. Install: pip install markdown"),
|
|
) from exc
|
|
|
|
if fmt == "pdf":
|
|
try:
|
|
from weasyprint import HTML
|
|
|
|
pdf_bytes = HTML(string=_md_to_html(content)).write_pdf()
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": "attachment; filename=report.pdf"},
|
|
)
|
|
except ImportError as exc:
|
|
raise HTTPException(
|
|
status_code=501,
|
|
detail=(
|
|
"PDF export requires 'weasyprint' and 'markdown'."
|
|
" Install: pip install pydantic-deep[export]"
|
|
),
|
|
) from exc
|
|
|
|
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt}. Use: md, html, pdf")
|
|
|
|
|
|
# Preview endpoint (for HTML/SVG live preview in file panel)
|
|
|
|
|
|
@app.get("/preview/{session_id}/{filepath:path}")
|
|
async def preview_file(session_id: str, filepath: str):
|
|
"""Serve raw files from container for live preview."""
|
|
|
|
if session_id not in user_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session = user_sessions[session_id]
|
|
|
|
if not filepath.startswith("/"):
|
|
filepath = "/" + filepath
|
|
|
|
ext = filepath.split(".")[-1].lower() if "." in filepath else ""
|
|
content_type = _CONTENT_TYPES.get(ext, "text/plain")
|
|
|
|
try:
|
|
result = session.deps.backend.read_bytes(filepath)
|
|
return Response(content=result, media_type=content_type)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
|
|
|
|
def main():
|
|
"""Run the DeepResearch server."""
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|