diff --git a/.github/workflows/docker-celery-smoke-test.yml b/.github/workflows/docker-celery-smoke-test.yml
new file mode 100644
index 000000000..e4aa39b86
--- /dev/null
+++ b/.github/workflows/docker-celery-smoke-test.yml
@@ -0,0 +1,90 @@
+name: Docker Celery Smoke Test
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and start stack
+ run: |
+ docker compose build
+ docker compose up -d
+
+ # Wait for server to be ready with retries
+ for i in {1..30}; do
+ if curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "Server is ready"
+ break
+ fi
+ echo "Waiting for server... attempt $i/30"
+ sleep 1
+ done
+ if ! curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "Server did not become ready in time"
+ docker compose logs web worker postgres redis || true
+ exit 1
+ fi
+
+ - name: Verify service health
+ run: |
+ docker compose ps
+ # Ensure key services are up before API checks
+ docker compose ps postgres | grep -E "Up|running"
+ docker compose ps redis | grep -E "Up|running"
+ docker compose ps web | grep -E "Up|running"
+ docker compose ps worker | grep -E "Up|running"
+
+ - name: Check /api/health
+ run: curl --fail http://127.0.0.1:8000/api/health
+
+ - name: Check /docs
+ run: curl --fail http://127.0.0.1:8000/docs
+
+ - name: Task create, interrupt, and poll terminal state
+ run: |
+ set -e
+ curl -s -o /dev/null -c cookies.txt -X POST http://127.0.0.1:8000/api/auth/signup -H "Content-Type: application/json" -d "{\"email\":\"user$RANDOM@test.com\", \"password\":\"password\", \"name\":\"test\"}"
+ TASK_JSON=$(curl -b cookies.txt -s -X POST http://127.0.0.1:8000/api/tasks -H "Content-Type: application/json" -d '{"prompt":"ping"}')
+ echo "Created: $TASK_JSON"
+ TASK_ID=$(echo "$TASK_JSON" | python -c "import sys, json; print(json.load(sys.stdin)['task_id'])")
+ if [ -z "$TASK_ID" ]; then echo "No task id"; exit 1; fi
+
+ # Interrupt quickly so this smoke test does not depend on LLM latency.
+ curl -b cookies.txt -s -X POST "http://127.0.0.1:8000/api/tasks/$TASK_ID/interrupt" || true
+
+ for i in {1..60}; do
+ STATUS_JSON=$(curl -b cookies.txt -s http://127.0.0.1:8000/api/tasks/$TASK_ID)
+ echo "Status: $STATUS_JSON"
+ STATUS=$(echo "$STATUS_JSON" | python -c "import sys, json; print(json.load(sys.stdin).get('status'))")
+ if [ "$STATUS" = "COMPLETED" ] || [ "$STATUS" = "FAILED" ] || [ "$STATUS" = "INTERRUPTED" ]; then
+ if [ "$STATUS" = "INTERRUPTED" ]; then
+ echo "Task interrupted as expected"
+ fi
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "Task did not complete in time"
+ exit 1
+
+ - name: Dump logs on failure
+ if: failure()
+ run: |
+ docker compose ps
+ docker compose logs web worker postgres redis
+
+ - name: Tear down
+ if: always()
+ run: docker compose down -v
diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml
new file mode 100644
index 000000000..9ac4fb854
--- /dev/null
+++ b/.github/workflows/docker-smoke-test.yml
@@ -0,0 +1,141 @@
+name: Docker Smoke Test
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: openmanus
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ redis:
+ image: redis:latest
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Build image
+ run: docker build -t openmanus:latest .
+
+ - name: Run container
+ run: |
+ docker run -d --network host --name openmanus \
+ -e DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/openmanus \
+ -e CELERY_BROKER_URL=redis://localhost:6379/0 \
+ -e CELERY_RESULT_BACKEND=redis://localhost:6379/0 \
+ openmanus:latest
+ # start websocket server inside container on port 8001
+ docker exec openmanus sh -c "uvicorn server.ws:app --host 0.0.0.0 --port 8001 --workers 1 >/tmp/ws.log 2>&1 & echo \$! >/tmp/ws.pid"
+
+ # Wait for server to be ready with retries
+ for i in {1..30}; do
+ if curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "Server is ready"
+ break
+ fi
+ echo "Waiting for server... attempt $i/30"
+ sleep 1
+ done
+ if ! curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "Server did not become ready in time"
+ docker logs openmanus || true
+ exit 1
+ fi
+
+ # Wait for websocket server readiness
+ for i in {1..20}; do
+ if curl -sf http://127.0.0.1:8001/health >/dev/null 2>&1; then
+ echo "WebSocket server is ready"
+ break
+ fi
+ echo "Waiting for websocket server... attempt $i/20"
+ sleep 1
+ done
+
+ - name: Check /api/health
+ run: curl --fail http://127.0.0.1:8000/api/health
+
+ - name: Check /docs
+ run: curl --fail http://127.0.0.1:8000/docs
+
+ - name: Install tooling for task test
+ run: |
+ sudo apt-get update && sudo apt-get install -y jq
+ python -m pip install --upgrade pip
+ pip install websockets
+
+ - name: Simulate task lifecycle with interrupt and event stream
+ run: |
+ set -e
+ curl -s -o /dev/null -c cookies.txt -X POST http://127.0.0.1:8000/api/auth/signup -H "Content-Type: application/json" -d "{\"email\":\"user$RANDOM@test.com\", \"password\":\"password\", \"name\":\"test\"}"
+ TASK_JSON=$(curl -b cookies.txt -s -X POST http://127.0.0.1:8000/api/tasks)
+ echo "Task created: $TASK_JSON"
+ TASK_ID=$(echo "$TASK_JSON" | jq -r '.task_id')
+ if [ -z "$TASK_ID" ] || [ "$TASK_ID" = "null" ]; then
+ echo "Failed to parse task id"; exit 1; fi
+ export TASK_ID
+
+ python - <<'PY'
+ import asyncio, json, websockets, os, sys
+ task_id = os.environ["TASK_ID"]
+ uri = f"ws://127.0.0.1:8001/tasks/{task_id}/stream"
+ events = []
+ async def main():
+ try:
+ async with websockets.connect(uri) as ws:
+ # send interrupt quickly to avoid long-running agent
+ await ws.send(json.dumps({"command": "interrupt"}))
+ try:
+ for _ in range(10):
+ msg = await asyncio.wait_for(ws.recv(), timeout=10)
+ events.append(msg)
+ if len(events) >= 2:
+ break
+ except asyncio.TimeoutError:
+ pass
+ except Exception as e:
+ print("WebSocket error:", e, file=sys.stderr)
+ sys.exit(1)
+ asyncio.run(main())
+ if not events:
+ print("No events received"); sys.exit(1)
+ print("Received events:", events)
+ PY
+
+ - name: Dump container logs on failure
+ if: failure()
+ run: |
+ docker logs openmanus || true
+ docker exec openmanus cat /tmp/ws.log || true
+ docker exec openmanus cat /tmp/server.log || true
+
+ - name: Stop container
+ if: always()
+ run: |
+ docker stop openmanus || true
+ docker rm openmanus || true
diff --git a/.github/workflows/web-smoke-test.yml b/.github/workflows/web-smoke-test.yml
new file mode 100644
index 000000000..b3862fffb
--- /dev/null
+++ b/.github/workflows/web-smoke-test.yml
@@ -0,0 +1,107 @@
+name: Web Smoke Test
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: openmanus
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+
+ - name: Wait for PostgreSQL
+ run: |
+ python - <<'PY'
+ import time
+ import psycopg2
+
+ for i in range(30):
+ try:
+ conn = psycopg2.connect(
+ host="localhost",
+ port=5432,
+ user="postgres",
+ password="postgres",
+ dbname="openmanus",
+ )
+ conn.close()
+ print("PostgreSQL is ready")
+ raise SystemExit(0)
+ except Exception as exc:
+ print(f"Waiting for PostgreSQL... {i+1}/30 ({exc})")
+ time.sleep(1)
+ print("PostgreSQL not ready in time")
+ raise SystemExit(1)
+ PY
+
+ - name: Start FastAPI server
+ env:
+ DATABASE_URL: postgresql+psycopg2://postgres:postgres@localhost:5432/openmanus
+ run: |
+ uvicorn server.api:app --host 0.0.0.0 --port 8000 --workers 1 > /tmp/server.log 2>&1 &
+ echo $! > server.pid
+
+ # Wait for server to be ready with retries
+ for i in {1..30}; do
+ if curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "Server is ready"
+ break
+ fi
+ echo "Waiting for server... attempt $i/30"
+ sleep 1
+ done
+
+ - name: Check /api/health
+ run: |
+ for i in {1..10}; do
+ if curl -sf http://127.0.0.1:8000/api/health > /tmp/health.json; then
+ cat /tmp/health.json
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "Health endpoint not responding"
+ cat /tmp/server.log || true
+ exit 1
+
+ - name: Check /docs
+ run: |
+ curl -sf http://127.0.0.1:8000/docs > /tmp/docs.html
+ grep -i "swagger" /tmp/docs.html
+
+ - name: Stop server
+ if: always()
+ run: |
+ if [ -f server.pid ]; then kill $(cat server.pid) || true; fi
+
+ - name: Dump server logs on failure
+ if: failure()
+ run: |
+ cat /tmp/server.log || true
diff --git a/.gitignore b/.gitignore
index 41dbbf2d8..818e6fcf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -134,7 +134,7 @@ __pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
-
+OpenManus.code-workspace
# SageMath parsed files
*.sage.py
diff --git a/.openhands/skills/code-review.md b/.openhands/skills/code-review.md
new file mode 100644
index 000000000..d4cfa3d68
--- /dev/null
+++ b/.openhands/skills/code-review.md
@@ -0,0 +1,18 @@
+---
+name: code-review
+type: knowledge
+version: 1.0
+agent: Manus
+triggers:
+ - review
+ - bug
+ - regression
+ - security
+ - test
+---
+Code review workflow:
+1. Prioritize correctness, regressions, missing tests, and security issues.
+2. Ground findings in exact files, functions, or observable behavior.
+3. Verify suspicious behavior by reading the caller and callee, not just one file.
+4. Keep style-only notes secondary unless they block maintainability.
+5. When implementing fixes, run focused tests and inspect the resulting diff.
diff --git a/.openhands/skills/docker.md b/.openhands/skills/docker.md
new file mode 100644
index 000000000..25259dcaa
--- /dev/null
+++ b/.openhands/skills/docker.md
@@ -0,0 +1,18 @@
+---
+name: docker
+type: knowledge
+version: 1.0
+agent: Manus
+triggers:
+ - docker
+ - container
+ - compose
+ - port
+ - server
+---
+Docker workflow:
+1. Inspect `docker compose ps`, container logs, and exposed ports before guessing.
+2. Keep long-running dev servers in the background and write logs to a file in the conversation workspace.
+3. After starting a server, verify it is listening with `ss -tlnp` or `curl`.
+4. Report the reachable URL and leave enough runtime evidence for the UI process panel.
+5. Do not stop OpenManus system containers unless explicitly asked.
diff --git a/.openhands/skills/frontend.md b/.openhands/skills/frontend.md
new file mode 100644
index 000000000..4819ef9d0
--- /dev/null
+++ b/.openhands/skills/frontend.md
@@ -0,0 +1,19 @@
+---
+name: frontend-ui
+type: knowledge
+version: 1.0
+agent: Manus
+triggers:
+ - ui
+ - frontend
+ - react
+ - page
+ - component
+ - css
+---
+Frontend workflow:
+1. Inspect existing components, routes, services, and styling utilities before editing.
+2. Prefer stable, scrollable layouts with clear empty/loading/error states.
+3. Use existing icons and controls; avoid text overflow inside buttons, badges, and panels.
+4. Build or type-check after changes, and use browser verification when a visual change is risky.
+5. Keep the primary user flow usable before adding decorative polish.
diff --git a/.openhands/skills/pdflatex.md b/.openhands/skills/pdflatex.md
new file mode 100644
index 000000000..23e70c6b2
--- /dev/null
+++ b/.openhands/skills/pdflatex.md
@@ -0,0 +1,18 @@
+---
+name: pdflatex
+type: knowledge
+version: 1.0
+agent: Manus
+triggers:
+ - latex
+ - tex
+ - pdf
+ - thesis
+ - compile
+---
+LaTeX workflow:
+1. Keep `.tex`, generated assets, `.log`, and final `.pdf` in the conversation workspace.
+2. Compile with `latexmk -pdf -interaction=nonstopmode` when available, otherwise use repeated `pdflatex`.
+3. Do not claim success until a non-empty PDF exists.
+4. If no PDF exists, inspect the `.log`, fix the first real error, and compile again.
+5. Summarize the output filename and any remaining warnings.
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 84be37205..d9f765f23 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ repos:
- id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
diff --git a/Dockerfile b/Dockerfile
index 9f7a19081..f2d3d6359 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,54 @@
+# syntax=docker/dockerfile:1.6
+
FROM python:3.12-slim
-WORKDIR /app/OpenManus
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+WORKDIR /app
+
+# Install dependencies
+RUN apt-get update && apt-get install -y \
+ gcc \
+ python3-dev \
+ libpq-dev \
+ libnss3 \
+ libnspr4 \
+ libatk1.0-0 \
+ libatk-bridge2.0-0 \
+ libcups2 \
+ libdrm2 \
+ libxkbcommon0 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxrandr2 \
+ libgbm1 \
+ libasound2 \
+ libpango-1.0-0 \
+ libcairo2 \
+ fonts-liberation \
+ libxfixes3 \
+ docker.io \
+ texlive-base \
+ texlive-latex-recommended \
+ texlive-latex-extra \
+ texlive-fonts-recommended \
+ texlive-fonts-extra \
+ texlive-science \
+ texlive-bibtex-extra \
+ latexmk \
+ && rm -rf /var/lib/apt/lists/*
-RUN apt-get update && apt-get install -y --no-install-recommends git curl \
- && rm -rf /var/lib/apt/lists/* \
- && (command -v uv >/dev/null 2>&1 || pip install --no-cache-dir uv)
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt \
+ && playwright install chromium \
+ && python -c "import cloakbrowser; cloakbrowser.ensure_binary()" \
+ && echo "CloakBrowser binary pre-downloaded"
COPY . .
-RUN uv pip install --system -r requirements.txt
+EXPOSE 8000
-CMD ["bash"]
+CMD ["uvicorn", "server.api:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..ff4f71b86
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,8 @@
+.PHONY: web-smoke build
+
+web-smoke:
+ ./scripts/local_web_smoke.sh
+
+build:
+ docker system prune -f && docker builder prune -f
+ docker compose up -d --build
diff --git a/README.md b/README.md
index d4a84374f..6f382bcef 100644
--- a/README.md
+++ b/README.md
@@ -1,195 +1,186 @@
-
+
-English | [中文](README_zh.md) | [한국어](README_ko.md) | [日本語](README_ja.md)
+English only.
-[](https://github.com/FoundationAgents/OpenManus/stargazers)
-
-[](https://opensource.org/licenses/MIT)
-[](https://discord.gg/DYn29wFk9z)
-[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
-[](https://doi.org/10.5281/zenodo.15186407)
+# OpenManus
-# 👋 OpenManus
+**A conversation-first agent runtime with persistent sandbox computers, live observability, and practical team workflows.**
-Manus is incredible, but OpenManus can achieve any idea without an *Invite Code* 🛫!
+This fork focuses on real daily usage: long-running conversations, isolated workspaces, runtime controls, and a UI that makes the agent’s work visible and manageable.
-Our team members [@Xinbin Liang](https://github.com/mannaandpoem) and [@Jinyu Xiang](https://github.com/XiangJinyu) (core authors), along with [@Zhaoyang Yu](https://github.com/MoshiQAQ), [@Jiayi Zhang](https://github.com/didiforgithub), and [@Sirui Hong](https://github.com/stellaHSR), we are from [@MetaGPT](https://github.com/geekan/MetaGPT). The prototype is launched within 3 hours and we are keeping building!
+## Why This Repo
-It's a simple implementation, so we welcome any suggestions, contributions, and feedback!
+OpenManus here is built for people who want an agent they can actually run, inspect, and improve:
-Enjoy your own agent with OpenManus!
+- Persistent conversations with replayable history
+- Per-conversation sandbox + workspace isolation
+- Live tool traces, terminal output, browser screenshots, and token counts
+- Runtime controls for processes, ports, and spawned containers
+- Optional RL policy scaffold compatible with OpenManus-RL workflows
-We're also excited to introduce [OpenManus-RL](https://github.com/OpenManus/OpenManus-RL), an open-source project dedicated to reinforcement learning (RL)- based (such as GRPO) tuning methods for LLM agents, developed collaboratively by researchers from UIUC and OpenManus.
+## What’s Implemented
-## Project Demo
+- Auth + admin bootstrap (first signup becomes admin)
+- Session-based web auth and admin settings UI/API
+- Conversation model with grouped tasks and follow-up continuity
+- Per-conversation files under `workspace/conversations//`
+- Reused sandbox computer per conversation
+- Mid-task user messages to running agents
+- Live SSE lifecycle stream (thoughts, tools, terminal, browser, usage)
+- Process/container visibility and kill controls (with protection rules)
+- Better completion artifacts and warnings (including missing PDF hints)
+- OpenHands-style skill loading support
+- RL integration scaffold and policy export helper
-
+### Latest Additions
-## Installation
+- `apply_patch_editor` as the primary code-editing tool for safer, atomic patch updates
+- Live and final GitHub-style change summary (`files changed`, `+added`, `-deleted`)
+- Richer tool execution cards with better status and file-change visibility
+- Improved conversation reliability around long runs and follow-up continuity
+- Runtime context observability (requested window, received window, usage ratio, auto-compress status)
-We provide two installation methods. Method 2 (using uv) is recommended for faster installation and better dependency management.
+## Architecture
-### Method 1: Using conda
+- Backend API: `server/api.py` (FastAPI + SSE)
+- Worker runtime: `server/tasks.py` (Celery)
+- Agent core: `app/agent/manus.py`, `app/agent/toolcall.py`
+- LLM compatibility/runtime: `app/llm.py`
+- Sandbox lifecycle: `app/sandbox/conversation.py`
+- Frontend: `frontend/` (React + Vite)
+- Persistence: PostgreSQL
+- Event transport: Redis Streams
-1. Create a new conda environment:
+## Quick Start (Docker)
-```bash
-conda create -n open_manus python=3.12
-conda activate open_manus
-```
-
-2. Clone the repository:
-
-```bash
-git clone https://github.com/FoundationAgents/OpenManus.git
-cd OpenManus
-```
-
-3. Install dependencies:
+1. Copy config:
```bash
-pip install -r requirements.txt
+cp config/config.example.toml config/config.toml
```
-### Method 2: Using uv (Recommended)
+2. Set your model endpoint in `config/config.toml`.
-1. Install uv (A fast Python package installer and resolver):
+3. Start:
```bash
-curl -LsSf https://astral.sh/uv/install.sh | sh
+docker compose up --build
```
-2. Clone the repository:
+Or use the project helper:
```bash
-git clone https://github.com/FoundationAgents/OpenManus.git
-cd OpenManus
+make build
```
-3. Create a new virtual environment and activate it:
+`make build` runs a clean Docker builder prune and then starts the stack with `docker compose up -d --build`.
-```bash
-uv venv --python 3.12
-source .venv/bin/activate # On Unix/macOS
-# Or on Windows:
-# .venv\Scripts\activate
-```
+4. Open:
-4. Install dependencies:
+- Frontend: `http://localhost:3000`
+- API: `http://localhost:8000`
-```bash
-uv pip install -r requirements.txt
-```
+## Local Python Start
-### Browser Automation Tool (Optional)
```bash
-playwright install
+uv venv --python 3.12
+source .venv/bin/activate
+uv pip install -r requirements.txt
+python main.py
```
## Configuration
-OpenManus requires configuration for the LLM APIs it uses. Follow these steps to set up your configuration:
+Main file: `config/config.toml`
-1. Create a `config.toml` file in the `config` directory (you can copy from the example):
+Important sections:
-```bash
-cp config/config.example.toml config/config.toml
-```
+- `[llm]` model, endpoint, key, token limits
+- `[sandbox]` runtime limits and network/socket access
+- `[agent]` max steps and tool-call behavior
+- `[rl]` optional policy integration
-2. Edit `config/config.toml` to add your API keys and customize settings:
+Example RL toggle:
```toml
-# Global LLM configuration
-[llm]
-model = "gpt-4o"
-base_url = "https://api.openai.com/v1"
-api_key = "sk-..." # Replace with your actual API key
-max_tokens = 4096
-temperature = 0.0
-
-# Optional configuration for specific LLM models
-[llm.vision]
-model = "gpt-4o"
-base_url = "https://api.openai.com/v1"
-api_key = "sk-..." # Replace with your actual API key
+[rl]
+enabled = true
+policy_mode = "rl"
+policy_path = "research/openmanus-rl/artifacts/policy/latest/policy.md"
+metadata_path = "research/openmanus-rl/artifacts/policy/latest/metadata.json"
```
-## Quick Start
-
-One line for run OpenManus:
+## RL Scaffold
-```bash
-python main.py
-```
+This repo includes a clean path for OpenManus-RL style integration:
-Then input your idea via terminal!
+- `research/openmanus-rl/`
+- `app/agent/policy_loader.py`
+- `scripts/export_policy.py`
-For MCP tool version, you can run:
-```bash
-python run_mcp.py
-```
-
-For unstable multi-agent version, you also can run:
+Example:
```bash
-python run_flow.py
+python scripts/export_policy.py \
+ --policy-file /path/to/policy.md \
+ --model qwen3.6-35b-a3b \
+ --benchmark gaia \
+ --run-id exp-2026-05-14
```
-### Custom Adding Multiple Agents
+## Semantic Memory with FAISS
-Currently, besides the general OpenManus Agent, we have also integrated the DataAnalysis Agent, which is suitable for data analysis and data visualization tasks. You can add this agent to `run_flow` in `config.toml`.
+OpenManus features local, zero-dependency SQLite-based memory persistence (`AgentMemory`). You can optionally enable local FAISS vector semantic search to enhance memory recall when query wording differs from saved memories:
```toml
-# Optional configuration for run-flow
-[runflow]
-use_data_analysis_agent = true # Disabled by default, change to true to activate
+[agentmemory]
+enabled = true
+vector_backend = "faiss" # "none" or "faiss"
+embedding_provider = "openai_compatible"
+embedding_model = "text-embedding-nomic-embed-text-v1.5"
+hybrid_search = true
+vector_weight = 0.65
+keyword_weight = 0.35
```
-In addition, you need to install the relevant dependencies to ensure the agent runs properly: [Detailed Installation Guide](app/tool/chart_visualization/README.md##Installation)
-## How to contribute
+When enabled, memories are indexed locally in FAISS alongside SQLite FTS5/BM25 keyword indices, returning weighted hybrid search results. If embedding generation or FAISS lookup fails, recall gracefully falls back to SQLite keyword search.
-We welcome any friendly suggestions and helpful contributions! Just create issues or submit pull requests.
+## DeepSpec Research Path
-Or contact @mannaandpoem via 📧email: mannaandpoem@gmail.com
+OpenManus includes an opt-in research integration scaffold for speculative decoding experiments using DeepSeek's DeepSpec framework:
-**Note**: Before submitting a pull request, please use the pre-commit tool to check your changes. Run `pre-commit run --all-files` to execute the checks.
+- See `research/deepspec/README.md` for workflow and evaluation instructions.
+- Run `scripts/prepare_deepspec_research.sh` to clone or update the upstream repository.
-## Community Group
-Join our networking group on Feishu and share your experience with other developers!
+**Note**: DeepSpec is strictly experimental and separate from standard runtime execution or default `make build` container generation. Target cache preparation requires high-capacity storage (e.g. ~38 TB for Qwen3-4B).
-
-
-
+## Contributing
-## Star History
+Contributions are very welcome.
-[](https://star-history.com/#FoundationAgents/OpenManus&Date)
+- Open an issue for bugs or feature proposals
+- Keep PRs focused and easy to review
+- Add verification steps in your PR description
-## Sponsors
-Thanks to [PPIO](https://ppinfra.com/user/register?invited_by=OCPKCN&utm_source=github_openmanus&utm_medium=github_readme&utm_campaign=link) for computing source support.
-> PPIO: The most affordable and easily-integrated MaaS and GPU cloud solution.
+### Maintainer Commitment
+I actively monitor this repo and **I will respond to pull requests quickly**.
+If your PR is blocked, tag me in the thread and I’ll help unblock it fast.
-## Acknowledgement
+### Suggested Team Flow
-Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo), [browser-use](https://github.com/browser-use/browser-use) and [crawl4ai](https://github.com/unclecode/crawl4ai) for providing basic support for this project!
+1. Create feature branches from `main`
+2. Open PRs early (draft PRs are welcome)
+3. Merge after review + verification
-Additionally, we are grateful to [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands) and [SWE-agent](https://github.com/SWE-agent/SWE-agent).
+## Runtime Notes
-We also thank stepfun(阶跃星辰) for supporting our Hugging Face demo space.
+- Sandboxes are scoped to conversation lifecycle.
+- Protected system processes/containers are not killable by runtime controls.
+- Deleting a conversation clears associated task records, streams, and sandbox state.
-OpenManus is built by contributors from MetaGPT. Huge thanks to this agent community!
+## License
-## Cite
-```bibtex
-@misc{openmanus2025,
- author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang and Bang Liu and Yuyu Luo and Chenglin Wu},
- title = {OpenManus: An open-source framework for building general AI agents},
- year = {2025},
- publisher = {Zenodo},
- doi = {10.5281/zenodo.15186407},
- url = {https://doi.org/10.5281/zenodo.15186407},
-}
-```
+MIT (same as project root license).
diff --git a/README_ja.md b/README_ja.md
deleted file mode 100644
index d5fd281e5..000000000
--- a/README_ja.md
+++ /dev/null
@@ -1,193 +0,0 @@
-
-
-## スター履歴
-
-[](https://star-history.com/#FoundationAgents/OpenManus&Date)
-
-## 謝辞
-
-このプロジェクトの基本的なサポートを提供してくれた[anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo)
-と[browser-use](https://github.com/browser-use/browser-use)に感謝します!
-
-さらに、[AAAJ](https://github.com/metauto-ai/agent-as-a-judge)、[MetaGPT](https://github.com/geekan/MetaGPT)、[OpenHands](https://github.com/All-Hands-AI/OpenHands)、[SWE-agent](https://github.com/SWE-agent/SWE-agent)にも感謝します。
-
-また、Hugging Face デモスペースをサポートしてくださった阶跃星辰 (stepfun)にも感謝いたします。
-
-OpenManusはMetaGPTのコントリビューターによって構築されました。このエージェントコミュニティに大きな感謝を!
-
-## 引用
-```bibtex
-@misc{openmanus2025,
- author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
- title = {OpenManus: An open-source framework for building general AI agents},
- year = {2025},
- publisher = {Zenodo},
- doi = {10.5281/zenodo.15186407},
- url = {https://doi.org/10.5281/zenodo.15186407},
-}
-```
diff --git a/README_ko.md b/README_ko.md
deleted file mode 100644
index 5080f43e7..000000000
--- a/README_ko.md
+++ /dev/null
@@ -1,192 +0,0 @@
-
-
-
-
-[English](README.md) | [中文](README_zh.md) | 한국어 | [日本語](README_ja.md)
-
-[](https://github.com/FoundationAgents/OpenManus/stargazers)
-
-[](https://opensource.org/licenses/MIT)
-[](https://discord.gg/DYn29wFk9z)
-[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
-[](https://doi.org/10.5281/zenodo.15186407)
-
-# 👋 OpenManus
-
-Manus는 놀라운 도구지만, OpenManus는 *초대 코드* 없이도 모든 아이디어를 실현할 수 있습니다! 🛫
-
-우리 팀의 멤버인 [@Xinbin Liang](https://github.com/mannaandpoem)와 [@Jinyu Xiang](https://github.com/XiangJinyu) (핵심 작성자), 그리고 [@Zhaoyang Yu](https://github.com/MoshiQAQ), [@Jiayi Zhang](https://github.com/didiforgithub), [@Sirui Hong](https://github.com/stellaHSR)이 함께 했습니다. 우리는 [@MetaGPT](https://github.com/geekan/MetaGPT)로부터 왔습니다. 프로토타입은 단 3시간 만에 출시되었으며, 계속해서 발전하고 있습니다!
-
-이 프로젝트는 간단한 구현에서 시작되었으며, 여러분의 제안, 기여 및 피드백을 환영합니다!
-
-OpenManus를 통해 여러분만의 에이전트를 즐겨보세요!
-
-또한 [OpenManus-RL](https://github.com/OpenManus/OpenManus-RL)을 소개하게 되어 기쁩니다. OpenManus와 UIUC 연구자들이 공동 개발한 이 오픈소스 프로젝트는 LLM 에이전트에 대해 강화 학습(RL) 기반 (예: GRPO) 튜닝 방법을 제공합니다.
-
-## 프로젝트 데모
-
-
-
-## 설치 방법
-
-두 가지 설치 방법을 제공합니다. **방법 2 (uv 사용)** 이 더 빠른 설치와 효율적인 종속성 관리를 위해 권장됩니다.
-
-### 방법 1: conda 사용
-
-1. 새로운 conda 환경을 생성합니다:
-
-```bash
-conda create -n open_manus python=3.12
-conda activate open_manus
-```
-
-2. 저장소를 클론합니다:
-
-```bash
-git clone https://github.com/FoundationAgents/OpenManus.git
-cd OpenManus
-```
-
-3. 종속성을 설치합니다:
-
-```bash
-pip install -r requirements.txt
-```
-
-### 방법 2: uv 사용 (권장)
-
-1. uv를 설치합니다. (빠른 Python 패키지 설치 및 종속성 관리 도구):
-
-```bash
-curl -LsSf https://astral.sh/uv/install.sh | sh
-```
-
-2. 저장소를 클론합니다:
-
-```bash
-git clone https://github.com/FoundationAgents/OpenManus.git
-cd OpenManus
-```
-
-3. 새로운 가상 환경을 생성하고 활성화합니다:
-
-```bash
-uv venv --python 3.12
-source .venv/bin/activate # Unix/macOS의 경우
-# Windows의 경우:
-# .venv\Scripts\activate
-```
-
-4. 종속성을 설치합니다:
-
-```bash
-uv pip install -r requirements.txt
-```
-
-### 브라우저 자동화 도구 (선택사항)
-```bash
-playwright install
-```
-
-## 설정 방법
-
-OpenManus를 사용하려면 사용하는 LLM API에 대한 설정이 필요합니다. 아래 단계를 따라 설정을 완료하세요:
-
-1. `config` 디렉토리에 `config.toml` 파일을 생성하세요 (예제 파일을 복사하여 사용할 수 있습니다):
-
-```bash
-cp config/config.example.toml config/config.toml
-```
-
-2. `config/config.toml` 파일을 편집하여 API 키를 추가하고 설정을 커스터마이징하세요:
-
-```toml
-# 전역 LLM 설정
-[llm]
-model = "gpt-4o"
-base_url = "https://api.openai.com/v1"
-api_key = "sk-..." # 실제 API 키로 변경하세요
-max_tokens = 4096
-temperature = 0.0
-
-# 특정 LLM 모델에 대한 선택적 설정
-[llm.vision]
-model = "gpt-4o"
-base_url = "https://api.openai.com/v1"
-api_key = "sk-..." # 실제 API 키로 변경하세요
-```
-
-## 빠른 시작
-
-OpenManus를 실행하는 한 줄 명령어:
-
-```bash
-python main.py
-```
-
-이후 터미널에서 아이디어를 작성하세요!
-
-MCP 도구 버전을 사용하려면 다음을 실행하세요:
-```bash
-python run_mcp.py
-```
-
-불안정한 멀티 에이전트 버전을 실행하려면 다음을 실행할 수 있습니다:
-
-```bash
-python run_flow.py
-```
-
-### 사용자 정의 다중 에이전트 추가
-
-현재 일반 OpenManus 에이전트 외에도 데이터 분석 및 데이터 시각화 작업에 적합한 DataAnalysis 에이전트를 통합했습니다. 이 에이전트를 `config.toml`의 `run_flow`에 추가할 수 있습니다.
-
-```toml
-# run-flow에 대한 선택적 구성
-[runflow]
-use_data_analysis_agent = true # 기본적으로 비활성화되어 있으며, 활성화하려면 true로 변경
-```
-
-또한, 에이전트가 제대로 작동하도록 관련 종속성을 설치해야 합니다: [상세 설치 가이드](app/tool/chart_visualization/README.md##Installation)
-
-## 기여 방법
-
-모든 친절한 제안과 유용한 기여를 환영합니다! 이슈를 생성하거나 풀 리퀘스트를 제출해 주세요.
-
-또는 📧 메일로 연락주세요. @mannaandpoem : mannaandpoem@gmail.com
-
-**참고**: pull request를 제출하기 전에 pre-commit 도구를 사용하여 변경 사항을 확인하십시오. `pre-commit run --all-files`를 실행하여 검사를 실행합니다.
-
-## 커뮤니티 그룹
-Feishu 네트워킹 그룹에 참여하여 다른 개발자들과 경험을 공유하세요!
-
-
-
-
-
-## Star History
-
-[](https://star-history.com/#FoundationAgents/OpenManus&Date)
-
-## 감사의 글
-
-이 프로젝트에 기본적인 지원을 제공해 주신 [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo)와
-[browser-use](https://github.com/browser-use/browser-use)에게 감사드립니다!
-
-또한, [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands), [SWE-agent](https://github.com/SWE-agent/SWE-agent)에 깊은 감사를 드립니다.
-
-또한 Hugging Face 데모 공간을 지원해 주신 阶跃星辰 (stepfun)에게 감사드립니다.
-
-OpenManus는 MetaGPT 기여자들에 의해 개발되었습니다. 이 에이전트 커뮤니티에 깊은 감사를 전합니다!
-
-## 인용
-```bibtex
-@misc{openmanus2025,
- author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
- title = {OpenManus: An open-source framework for building general AI agents},
- year = {2025},
- publisher = {Zenodo},
- doi = {10.5281/zenodo.15186407},
- url = {https://doi.org/10.5281/zenodo.15186407},
-}
-```
diff --git a/README_zh.md b/README_zh.md
deleted file mode 100644
index 680ccc1d9..000000000
--- a/README_zh.md
+++ /dev/null
@@ -1,198 +0,0 @@
-
+ {enableThinking === 'auto'
+ ? 'Auto: detected from LM-Studio /api/v0/models or from known model names (Claude 3.7, QwQ, DeepSeek-R1…)'
+ : enableThinking === 'on'
+ ? 'Always on: thinking budget injected into every request for this conversation.'
+ : 'Disabled: thinking tokens suppressed — faster, lower-cost responses.'}
+
+
+
+ {/* Performance mode */}
+
+
+ Performance Mode
+
+ Disables streaming previews and reduces UI refresh rate. Useful for long background tasks.
+
+
+ setPerformanceMode(Boolean(v))}
+ />
+
+
+
+
+
+
+ Identity Memory
+ Persistent profile notes injected into every run context.
+
+
+ Profile Notes
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
new file mode 100644
index 000000000..fecd96323
--- /dev/null
+++ b/frontend/src/pages/HomePage.tsx
@@ -0,0 +1,171 @@
+import { createTask } from '@/services/tasks';
+import { ChatInput } from '@/components/features/chat/input';
+import { useConversations } from '@/hooks/use-conversations';
+import { useRecentTasks } from '@/hooks/use-tasks';
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { CodeIcon, GlobeIcon, FileTextIcon, SearchIcon, BrainIcon, MessageSquareIcon, ClockIcon } from 'lucide-react';
+
+const SUGGESTED_PROMPTS = [
+ { icon: CodeIcon, label: 'Write code', prompt: 'Write a Python script that reads a CSV file and outputs a summary report.' },
+ { icon: GlobeIcon, label: 'Browse web', prompt: 'Search the web for the latest AI research papers published this week.' },
+ { icon: FileTextIcon, label: 'Edit files', prompt: 'Refactor my codebase to add proper error handling and logging throughout.' },
+ { icon: SearchIcon, label: 'Research', prompt: 'Research and summarize the key differences between React 18 and React 19.' },
+ { icon: BrainIcon, label: 'Plan a project',prompt: 'Create a detailed project plan for building a REST API with FastAPI and PostgreSQL.' },
+ { icon: MessageSquareIcon, label: 'Explain code', prompt: 'Explain how this codebase works and identify potential improvements.' },
+];
+
+const CAPABILITIES = [
+ { label: '💻 Code', title: 'Write, edit, and debug code' },
+ { label: '🌐 Browse', title: 'Search and browse the web' },
+ { label: '📁 Files', title: 'Read and edit files' },
+ { label: '🔍 Search', title: 'Search codebases and docs' },
+ { label: '🧠 Plan', title: 'Break down complex tasks' },
+ { label: '⚙️ Run', title: 'Execute scripts and commands' },
+];
+
+export default function HomePage({ selectedModel }: { selectedModel?: string }) {
+ const navigate = useNavigate();
+ const params = useParams();
+ const [isLoading, setIsLoading] = useState(false);
+ const [draftPrompt, setDraftPrompt] = useState('');
+ const abortControllerRef = useRef(null);
+ const { refreshTasks } = useRecentTasks();
+ const { activeConversationId, ensureConversation, refreshConversations, setActiveConversationId } = useConversations();
+
+ useEffect(() => {
+ if (params.conversationId) setActiveConversationId(params.conversationId);
+ }, [params.conversationId, setActiveConversationId]);
+
+ useEffect(() => {
+ return () => { abortControllerRef.current?.abort(); };
+ }, []);
+
+ const handleSubmit = async (input: { prompt: string }) => {
+ if (!input || isLoading) return;
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+ setIsLoading(true);
+ try {
+ const conversationId = params.conversationId || activeConversationId || (await ensureConversation());
+ const res = await createTask({ prompt: input.prompt, conversationId, model: selectedModel });
+ if (res.error || !res.data) throw new Error('Failed to create task');
+ await refreshTasks();
+ await refreshConversations();
+ navigate(`/conversations/${res.data.conversation_id || conversationId}`);
+ } catch (error: any) {
+ if (error.name === 'AbortError') return;
+ console.error('Error:', error);
+ } finally {
+ setIsLoading(false);
+ abortControllerRef.current = null;
+ }
+ };
+
+ return (
+
+ {/* Main content area */}
+
+
+ {/* Logo + tagline */}
+
+
+ 🤖
+
+
OpenManus
+
+ An autonomous AI agent that codes, browses, and reasons — no fortress, purely open ground.
+
{
+ handleScroll();
+ const el = messagesContainerRef.current;
+ if (!el) return;
+ // Lazy-reveal older history only when the user intentionally scrolls up.
+ if (el.scrollTop < 120 && historyWindow < messages.length) {
+ const prevHeight = el.scrollHeight;
+ setHistoryWindow(windowSize => Math.min(messages.length, windowSize + 80));
+ requestAnimationFrame(() => {
+ const target = messagesContainerRef.current;
+ if (!target) return;
+ const diff = target.scrollHeight - prevHeight;
+ target.scrollTop = target.scrollTop + diff;
+ });
+ }
+ }}
+ >
+
+
+
+
+
+ {!isPreviewCollapsed && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts
new file mode 100644
index 000000000..b6615e58d
--- /dev/null
+++ b/frontend/src/services/admin.ts
@@ -0,0 +1,54 @@
+import type { ModelOption } from './models';
+
+export interface ToolOption {
+ name: string;
+ label: string;
+ scope: string;
+ enabled?: boolean;
+ locked?: boolean;
+}
+
+export interface AdminSettings {
+ llm_connection: {
+ model?: string;
+ base_url?: string;
+ api_key?: string;
+ api_type?: string;
+ max_tokens?: number;
+ temperature?: number;
+ thinking_budget?: number;
+ max_steps?: number;
+ fallback_chain?: Array<{
+ model?: string;
+ base_url?: string;
+ api_key?: string;
+ api_type?: string;
+ max_tokens?: number;
+ temperature?: number;
+ thinking_budget?: number;
+ }>;
+ };
+ llm_connection_override?: AdminSettings['llm_connection'];
+ tools: { disabled: string[] };
+ config_defaults?: Record;
+ config_overrides?: Record;
+ available_tools: ToolOption[];
+ models?: ModelOption[];
+}
+
+export async function getAdminSettings(): Promise {
+ const response = await fetch('/api/admin/settings', { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('Admin access required');
+ return response.json();
+}
+
+export async function updateAdminSettings(settings: Partial): Promise {
+ const response = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings),
+ });
+ if (!response.ok) throw new Error('Could not save admin settings');
+ return response.json();
+}
diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts
new file mode 100644
index 000000000..9c1a161bb
--- /dev/null
+++ b/frontend/src/services/auth.ts
@@ -0,0 +1,51 @@
+export interface User {
+ id: string;
+ email: string;
+ name: string;
+ role: string;
+}
+
+export async function getMe(): Promise<{ user?: User }> {
+ const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
+ if (!response.ok) return {};
+ return response.json();
+}
+
+export async function login(email: string, password: string): Promise<{ user?: User; error?: string }> {
+ try {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(data.detail || 'Could not sign in');
+ return { user: data.user };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Could not sign in' };
+ }
+}
+
+export async function signup(name: string, email: string, password: string): Promise<{ user?: User; error?: string }> {
+ try {
+ const response = await fetch('/api/auth/signup', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email, password }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(data.detail || 'Could not create account');
+ return { user: data.user };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Could not create account' };
+ }
+}
+
+export async function logout(): Promise {
+ await fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+}
diff --git a/frontend/src/services/conversations.ts b/frontend/src/services/conversations.ts
new file mode 100644
index 000000000..673f2df48
--- /dev/null
+++ b/frontend/src/services/conversations.ts
@@ -0,0 +1,353 @@
+export interface Conversation {
+ id: string;
+ conversation_id: string;
+ title: string;
+ model?: string | null;
+ settings?: {
+ disabled_tools?: string[];
+ requested_context_window?: number;
+ auto_context_compress?: boolean;
+ disabled_skills?: string[];
+ enable_vendor_skills?: boolean;
+ pinned_skills?: string[];
+ identity_notes?: string;
+ auto_skill_curator?: boolean;
+ skill_suggestions?: Array<{
+ key: string;
+ tools: string[];
+ count: number;
+ last_seen?: number;
+ last_prompt?: string;
+ }>;
+ };
+ context?: {
+ requested_window?: number | null;
+ received_window?: number | null;
+ received_window_source?: string | null;
+ current_input_tokens?: number;
+ usage_ratio?: number | null;
+ is_near_limit?: boolean;
+ auto_context_compress?: boolean;
+ } | null;
+ latest_task_id?: string | null;
+ latest_status?: string | null;
+ state?: 'idle' | 'running' | 'paused' | 'waiting' | 'finished' | 'error' | 'stuck';
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+export interface ConversationEvent {
+ id?: string;
+ type: 'progress';
+ name: string;
+ content: Record;
+ task_id?: string;
+ created_at?: string | null;
+}
+
+export interface ConversationHistory {
+ conversation: Conversation;
+ tasks: Array<{
+ id: string;
+ task_id: string;
+ status?: string;
+ request?: string;
+ conversation_id?: string;
+ created_at?: string | null;
+ }>;
+ events: ConversationEvent[];
+ pagination?: {
+ limit?: number;
+ next_before_event_id?: number | null;
+ has_more?: boolean;
+ };
+}
+
+export interface ConversationRuntime {
+ conversation_id: string;
+ status: 'running' | 'idle';
+ running_count: number;
+ hidden_system_containers?: number;
+ agentmemory?: IntegrationsHealth['agentmemory'];
+ sandbox?: {
+ exists: boolean;
+ status: string;
+ container?: {
+ id: string;
+ name: string;
+ image: string;
+ } | null;
+ };
+ urls?: Array<{
+ port: string;
+ url: string;
+ pid: number;
+ command: string;
+ args: string;
+ }>;
+ processes: Array<{
+ pid: number;
+ ppid: number;
+ stat: string;
+ elapsed: string;
+ command: string;
+ args: string;
+ ports?: string[];
+ zombie?: boolean;
+ protected?: boolean;
+ }>;
+ containers: Array<{
+ id: string;
+ name: string;
+ image: string;
+ status: string;
+ command: string;
+ protected?: boolean;
+ }>;
+}
+
+export interface SkillSummary {
+ name: string;
+ path: string;
+ type: string;
+ version: string;
+ agent: string;
+ triggers: string[];
+ enabled?: boolean;
+}
+
+export interface ObsidianGraph {
+ conversation_id: string;
+ node_count: number;
+ edge_count: number;
+ nodes: Array<{
+ id: string;
+ path: string;
+ title: string;
+ tags?: string[];
+ updated_at?: string | null;
+ }>;
+ edges: Array<{
+ id: string;
+ source: string;
+ target: string;
+ relation: string;
+ }>;
+}
+
+export interface IntegrationsHealth {
+ conversation_id: string;
+ agentmemory: {
+ enabled: boolean;
+ available: boolean;
+ live: boolean;
+ reason?: string;
+ base_url?: string;
+ project?: string;
+ conversation_hits?: number;
+ vector_backend?: string;
+ vector_live?: boolean;
+ vector_count?: number;
+ embedding_provider?: string;
+ last_vector_error?: string | null;
+ };
+ obsidian: {
+ enabled: boolean;
+ available: boolean;
+ live: boolean;
+ reason?: string;
+ note_count?: number;
+ };
+ llm_connection?: {
+ configured: boolean;
+ live: boolean;
+ reason?: string;
+ api_type?: string;
+ base_url?: string;
+ model_count?: number;
+ };
+}
+
+export async function listConversations(): Promise<{ conversations: Conversation[] }> {
+ const response = await fetch('/api/conversations', {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) return { conversations: [] };
+ return response.json();
+}
+
+export async function createConversation(title = 'New conversation', model?: string): Promise {
+ const response = await fetch('/api/conversations', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, model }),
+ });
+ if (!response.ok) throw new Error('Could not create conversation');
+ return response.json();
+}
+
+export async function getConversation(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not load conversation');
+ return response.json();
+}
+
+export async function getConversationHistory(conversationId: string, limit = 160): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/events/history?limit=${encodeURIComponent(String(limit))}`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not load conversation history');
+ return response.json();
+}
+
+export async function getConversationHistoryAll(conversationId: string): Promise {
+ const pageSize = 1000;
+ let beforeEventId: number | null | undefined = undefined;
+ let conversation: Conversation | undefined;
+ let tasks: ConversationHistory['tasks'] = [];
+ const events: ConversationEvent[] = [];
+
+ for (let page = 0; page < 10; page += 1) {
+ const suffix = beforeEventId ? `&before_event_id=${beforeEventId}` : '';
+ const response = await fetch(
+ `/api/conversations/${conversationId}/events/history?limit=${pageSize}${suffix}`,
+ { credentials: 'same-origin' },
+ );
+ if (!response.ok) throw new Error('Could not load conversation history');
+ const data: ConversationHistory = await response.json();
+ conversation = data.conversation;
+ tasks = data.tasks || tasks;
+ events.push(...(data.events || []));
+ const pagination = data.pagination || {};
+ if (!pagination.has_more || !pagination.next_before_event_id) {
+ break;
+ }
+ beforeEventId = pagination.next_before_event_id;
+ }
+
+ return {
+ conversation: conversation as Conversation,
+ tasks,
+ events,
+ };
+}
+
+export async function getConversationRuntime(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/runtime`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not load conversation runtime');
+ return response.json();
+}
+
+export async function killConversationProcess(conversationId: string, pid: number): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/runtime/processes/${pid}/kill`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signal: 'TERM' }),
+ });
+ if (!response.ok) throw new Error('Could not kill process');
+}
+
+export async function stopConversationContainer(conversationId: string, containerId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/runtime/containers/${containerId}/stop`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not stop container');
+}
+
+export async function pauseConversationSandbox(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/sandbox/pause`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not pause sandbox');
+}
+
+export async function resumeConversationSandbox(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/sandbox/resume`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not resume sandbox');
+}
+
+export async function listSkills(conversationId?: string): Promise<{ skills: SkillSummary[] }> {
+ const suffix = conversationId ? `?conversation_id=${encodeURIComponent(conversationId)}` : '';
+ const response = await fetch(`/api/skills${suffix}`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) return { skills: [] };
+ return response.json();
+}
+
+export async function getObsidianGraph(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/obsidian/graph`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not load vault graph');
+ return response.json();
+}
+
+export async function getIntegrationsHealth(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/integrations/health`, {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not load integrations health');
+ return response.json();
+}
+
+export async function sendConversationMessage(
+ conversationId: string,
+ message: string,
+ model?: string,
+): Promise<{ conversation_id: string; task_id: string; queued: boolean; created_task: boolean }> {
+ const response = await fetch(`/api/conversations/${conversationId}/messages`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message, model }),
+ });
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.detail || 'Could not send message');
+ }
+ return response.json();
+}
+
+export async function deleteConversation(conversationId: string): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}`, {
+ method: 'DELETE',
+ credentials: 'same-origin',
+ });
+ if (!response.ok) throw new Error('Could not delete conversation');
+}
+
+export async function updateConversationSettings(
+ conversationId: string,
+ settings: {
+ model?: string;
+ disabled_tools?: string[];
+ requested_context_window?: number | null;
+ auto_context_compress?: boolean;
+ disabled_skills?: string[];
+ enable_vendor_skills?: boolean;
+ pinned_skills?: string[];
+ identity_notes?: string;
+ auto_skill_curator?: boolean;
+ },
+): Promise {
+ const response = await fetch(`/api/conversations/${conversationId}/settings`, {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings),
+ });
+ if (!response.ok) throw new Error('Could not update conversation settings');
+ return response.json();
+}
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
new file mode 100644
index 000000000..2815106b6
--- /dev/null
+++ b/frontend/src/services/index.ts
@@ -0,0 +1,9 @@
+import { createTask, pageTasks, terminateTask } from './tasks';
+
+export default {
+ tasks: {
+ createTask,
+ terminateTask,
+ pageTasks,
+ },
+};
diff --git a/frontend/src/services/models.ts b/frontend/src/services/models.ts
new file mode 100644
index 000000000..d7eb6a11f
--- /dev/null
+++ b/frontend/src/services/models.ts
@@ -0,0 +1,68 @@
+export interface ModelOption {
+ id: string;
+ name: string;
+ api_type: string;
+ state?: string;
+ instance_id?: string;
+ base_model?: string;
+ variant_tag?: string;
+ raw_model_key?: string;
+}
+
+export async function listModels(): Promise {
+ const response = await fetch('/api/models', { credentials: 'same-origin' });
+ if (!response.ok) return [];
+ const data = await response.json();
+ return data.models || [];
+}
+
+export async function queryModels(payload: {
+ host: string;
+ api_key?: string;
+ style: 'lm-studio' | 'ollama' | 'openai' | 'custom';
+ models_path?: string;
+}): Promise {
+ const response = await fetch('/api/models/query', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) return [];
+ const data = await response.json().catch(() => ({}));
+ return data.models || [];
+}
+
+export async function loadModel(payload: {
+ host: string;
+ api_key?: string;
+ style: 'lm-studio' | 'ollama' | 'openai' | 'custom';
+ model: string;
+ context_length?: number;
+}): Promise<{ ok: boolean; detail?: string }> {
+ const response = await fetch('/api/models/load', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || !data?.ok) {
+ return { ok: false, detail: data?.detail || 'Could not load model' };
+ }
+ return { ok: true };
+}
+
+export async function ejectModel(model?: string): Promise<{ ok: boolean; instance_id?: string; detail?: string }> {
+ const response = await fetch('/api/models/eject', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(model ? { model } : {}),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ return { ok: false, detail: data?.detail || 'Could not eject model' };
+ }
+ return { ok: true, instance_id: data?.instance_id };
+}
diff --git a/frontend/src/services/tasks.ts b/frontend/src/services/tasks.ts
new file mode 100644
index 000000000..eb488a266
--- /dev/null
+++ b/frontend/src/services/tasks.ts
@@ -0,0 +1,180 @@
+// 临时的tasks actions文件,用于解决导入错误
+// 实际实现需要根据后端API进行调整
+
+export interface CreateTaskParams {
+ taskId?: string;
+ conversationId?: string;
+ model?: string;
+ prompt: string;
+}
+
+export interface Task {
+ id: string;
+ created_at: string;
+ request: string;
+ status?: string;
+ result?: unknown;
+ conversation_id?: string;
+}
+
+export interface PageTasksParams {
+ page: number;
+ pageSize: number;
+}
+
+export interface PageTasksResult {
+ tasks: Task[];
+ total: number;
+}
+
+export interface GetTaskParams {
+ taskId: string;
+}
+
+export interface GetTaskEventsParams {
+ taskId: string;
+}
+
+export interface SendTaskMessageParams {
+ taskId: string;
+ message: string;
+}
+
+export interface TerminateTaskParams {
+ taskId: string;
+}
+
+export interface ShareTaskParams {
+ taskId: string;
+}
+
+export async function createTask(
+ params: CreateTaskParams,
+): Promise<{ data?: { task_id: string; message: string; conversation_id?: string }; error?: string; status?: number }> {
+ try {
+ const formData = new FormData();
+
+ if (params.taskId) {
+ formData.append('task_id', params.taskId);
+ }
+ if (params.conversationId) {
+ formData.append('conversation_id', params.conversationId);
+ }
+ if (params.model) {
+ formData.append('model', params.model);
+ }
+ formData.append('prompt', params.prompt);
+
+ const response = await fetch('/api/tasks', {
+ method: 'POST',
+ credentials: 'same-origin',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ let detail = 'Failed to create task';
+ try {
+ const payload = await response.json();
+ detail = payload.detail || payload.error || detail;
+ } catch {
+ // keep default detail
+ }
+ return { error: detail, status: response.status };
+ }
+
+ const data = await response.json();
+ return { data, status: response.status };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
+export function getTaskEvents(params: GetTaskEventsParams): EventSource {
+ return new EventSource(`/api/tasks/${params.taskId}/events`, { withCredentials: true });
+}
+
+export async function getTask(params: GetTaskParams): Promise<{ data?: Task; error?: string }> {
+ try {
+ const response = await fetch(`/api/tasks/${params.taskId}`, {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch task');
+ }
+
+ const data = await response.json();
+ return { data };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
+export async function sendTaskMessage(params: SendTaskMessageParams): Promise<{ data?: { id: string; queued: boolean }; error?: string }> {
+ try {
+ const response = await fetch(`/api/tasks/${params.taskId}/message`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ message: params.message }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to send task message');
+ }
+
+ const data = await response.json();
+ return { data };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
+export async function terminateTask(params: TerminateTaskParams): Promise<{ data?: Task; error?: string }> {
+ try {
+ const response = await fetch(`/api/tasks/${params.taskId}/terminate`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to terminate task');
+ }
+
+ const data = await response.json();
+ return { data };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
+export async function pageTasks(params: PageTasksParams): Promise<{ data?: PageTasksResult; error?: string }> {
+ try {
+ // 这里应该调用实际的API
+ const response = await fetch(`/api/tasks?page=${params.page}&pageSize=${params.pageSize}`, {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch tasks');
+ }
+
+ const data = await response.json();
+ return { data };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
diff --git a/frontend/src/services/tools.ts b/frontend/src/services/tools.ts
new file mode 100644
index 000000000..6c7c267be
--- /dev/null
+++ b/frontend/src/services/tools.ts
@@ -0,0 +1,8 @@
+import type { ToolOption } from './admin';
+
+export async function listTools(): Promise {
+ const response = await fetch('/api/tools', { credentials: 'same-origin' });
+ if (!response.ok) return [];
+ const data = await response.json();
+ return data.tools || [];
+}
diff --git a/frontend/src/styles/animations.css b/frontend/src/styles/animations.css
new file mode 100644
index 000000000..a55b2f609
--- /dev/null
+++ b/frontend/src/styles/animations.css
@@ -0,0 +1,85 @@
+@keyframes thinking {
+ 0%,
+ 60% {
+ transform: scale(1) rotate(0deg);
+ }
+ 70% {
+ transform: scale(1.5) rotate(-8deg);
+ }
+ 80% {
+ transform: scale(1.5) rotate(8deg);
+ }
+ 90% {
+ transform: scale(1.5) rotate(-8deg);
+ }
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+}
+
+.thinking-animation {
+ animation: thinking 3s ease-in-out infinite;
+ display: inline-block;
+}
+
+@keyframes searching {
+ 0% {
+ transform: translate(1px, -1.75px);
+ }
+ 8.33% {
+ transform: translate(1.75px, -1px);
+ }
+ 16.67% {
+ transform: translate(2px, 0);
+ }
+ 25% {
+ transform: translate(1.75px, 1px);
+ }
+ 33.33% {
+ transform: translate(1px, 1.75px);
+ }
+ 41.67% {
+ transform: translate(0, 2px);
+ }
+ 50% {
+ transform: translate(-1px, 1.75px);
+ }
+ 58.33% {
+ transform: translate(-1.75px, 1px);
+ }
+ 66.67% {
+ transform: translate(-2px, 0);
+ }
+ 75% {
+ transform: translate(-1.75px, -1px);
+ }
+ 83.33% {
+ transform: translate(-1px, -1.75px);
+ }
+ 91.67% {
+ transform: translate(0, -2px);
+ }
+ 100% {
+ transform: translate(1px, -1.75px);
+ }
+}
+
+.searching-animation {
+ animation: searching 1.5s linear infinite;
+ display: inline-block;
+ position: relative;
+}
+
+@keyframes spinning {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.spinning-animation {
+ animation: spinning 2s linear infinite;
+ display: inline-block;
+}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
new file mode 100644
index 000000000..ee7b6b8d0
--- /dev/null
+++ b/frontend/src/styles/globals.css
@@ -0,0 +1,139 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+@import 'github-markdown-css/github-markdown.css';
+@import '../styles/animations.css';
+
+body {
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+.markdown-body.chat {
+ @apply bg-muted;
+ font-size: 0.875rem;
+
+ pre,
+ code {
+ white-space: pre-wrap;
+ word-break: break-all;
+ }
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 000000000..1f1c3328b
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_NEED_INVITE_CODE: string;
+ readonly VITE_API_BASE_URL: string;
+ readonly VITE_JWT_SECRET: string;
+ // 更多环境变量...
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 000000000..a4434fbed
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 000000000..bf085fef3
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 000000000..9361bcc4f
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 000000000..52131be78
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,26 @@
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://web:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/main.py b/main.py
index 259c735be..a03a85256 100644
--- a/main.py
+++ b/main.py
@@ -1,35 +1,26 @@
import argparse
import asyncio
-from app.agent.manus import Manus
from app.logger import logger
async def main():
- # Parse command line arguments
- parser = argparse.ArgumentParser(description="Run Manus agent with a prompt")
- parser.add_argument(
- "--prompt", type=str, required=False, help="Input prompt for the agent"
+ """Placeholder entry point.
+
+ The CLI no longer instantiates or runs agents directly. Use the library
+ APIs or dedicated launcher scripts to start agents or services.
+ """
+
+ parser = argparse.ArgumentParser(
+ description="OpenManus entry point (agent execution disabled)"
+ )
+ parser.add_argument("--prompt", type=str, required=False, help="No-op placeholder")
+ _ = parser.parse_args()
+
+ logger.info(
+ "OpenManus CLI placeholder: agent execution is intentionally disabled in"
+ " this entry script."
)
- args = parser.parse_args()
-
- # Create and initialize Manus agent
- agent = await Manus.create()
- try:
- # Use command line prompt if provided, otherwise ask for input
- prompt = args.prompt if args.prompt else input("Enter your prompt: ")
- if not prompt.strip():
- logger.warning("Empty prompt provided.")
- return
-
- logger.warning("Processing your request...")
- await agent.run(prompt)
- logger.info("Request processing completed.")
- except KeyboardInterrupt:
- logger.warning("Operation interrupted.")
- finally:
- # Ensure agent resources are cleaned up before exiting
- await agent.cleanup()
if __name__ == "__main__":
diff --git a/openmanus_mcp_server/README.md b/openmanus_mcp_server/README.md
new file mode 100644
index 000000000..f902883ee
--- /dev/null
+++ b/openmanus_mcp_server/README.md
@@ -0,0 +1,146 @@
+# OpenManus MCP Server
+
+Model Context Protocol (MCP) server that exposes OpenManus autonomous agent capabilities to VS Code extensions (Codex, Antigravity) and other MCP clients.
+
+## Features
+
+- **11 MCP Tools**: Task execution, file operations, code execution, web search, git operations, and more
+- **4 MCP Resources**: Configuration, prompt templates
+- **5 MCP Prompts**: Code review, bug fix, feature implementation, refactoring, test generation
+- **Dual Transport**: Stdio (default) and SSE (for remote access)
+- **Task Management**: Full lifecycle with status tracking and cancellation
+
+## Installation
+
+### Prerequisites
+
+- Python 3.10+
+- Node.js (for using developer validation tools/MCP Inspector)
+
+### Install Dependencies
+
+From the workspace root directory:
+```bash
+pip install -r openmanus_mcp_server/requirements.txt
+```
+
+## Usage
+
+You can run, test, and debug the MCP server completely standalone using the standard transports or the official **MCP Developer tools**.
+
+### 🛠️ 1. Standalone Debugging via MCP Inspector (Highly Recommended)
+
+The Model Context Protocol team provides a web-based **MCP Inspector** utility. This lets you inspect and trigger tools interactively in a beautiful graphical interface without any IDE integration:
+
+```bash
+npx @modelcontextprotocol/inspector python -m openmanus_mcp_server
+```
+
+When you run this command:
+1. It launches a local web server at `http://localhost:5173` (or opens it automatically).
+2. It establishes a standard stdio connection with the Python server.
+3. You can click on **Tools**, fill out the input arguments (like `task` inside `openmanus_run_task`), click **Run Tool**, and watch the live OpenManus agent run directly in your terminal!
+
+---
+
+### 📥 2. Stdio Transport Mode (For IDEs and parent-child shells)
+
+To run the server in standard I/O mode:
+```bash
+python -m openmanus_mcp_server
+```
+
+---
+
+### 🌐 3. SSE Transport Mode (HTTP Web Server)
+
+To host the MCP server as a network-accessible web service:
+```bash
+python -m openmanus_mcp_server --sse --port 8765
+```
+
+This launches a FastAPI application at `http://localhost:8765`:
+* **SSE Endpoint**: `http://localhost:8765/sse` (to establish the stream connection)
+* **Message POST Endpoint**: `http://localhost:8765/messages/` (to send JSON-RPC payloads)
+
+---
+
+### ⚙️ 4. Custom Configuration Integration
+
+To execute with a specific OpenManus config file:
+```bash
+python -m openmanus_mcp_server --config config/config.toml
+```
+
+## Available Tools
+
+| Tool | Description |
+|------|-------------|
+| `openmanus_run_task` | Execute an autonomous task |
+| `openmanus_get_status` | Get task status |
+| `openmanus_cancel_task` | Cancel a running task |
+| `openmanus_list_tasks` | List all tasks |
+| `openmanus_read_output` | Read task output |
+| `openmanus_code_execute` | Execute code in sandbox |
+| `openmanus_file_read` | Read file contents |
+| `openmanus_file_write` | Write file contents |
+| `openmanus_web_search` | Search the web |
+| `openmanus_git_operation` | Execute Git operations |
+| `openmanus_list_files` | List directory contents |
+
+## Configuration
+
+Create a config file (`~/.openmanus/config.yaml`):
+
+```yaml
+llm:
+ model: gpt-4o
+ api_key: sk-...
+
+agent:
+ max_steps: 30
+ output_dir: ~/.openmanus/output
+```
+
+## Architecture
+
+```
+MCP Client (VS Code Extension)
+ ↓ (stdio or SSE)
+OpenManus MCP Server
+ ↓
+OpenManus Agent Engine
+ ↓
+LLM Provider / Browser / File System
+```
+
+## Development
+
+```bash
+# Run in development mode
+python -m openmanus_mcp_server
+
+# Run tests
+pytest tests/
+
+# Lint
+flake8 openmanus_mcp_server/
+mypy openmanus_mcp_server/
+```
+
+## Security
+
+- API keys stored in memory only
+- Path traversal protection
+- Workspace boundary enforcement
+- SSE requires authentication (when enabled)
+
+## License
+
+MIT
+
+## References
+
+- [MCP Specification](https://modelcontextprotocol.io)
+- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
+- [OpenManus](https://github.com/mannaandpoem/OpenManus)
diff --git a/openmanus_mcp_server/__init__.py b/openmanus_mcp_server/__init__.py
new file mode 100644
index 000000000..1507dcc04
--- /dev/null
+++ b/openmanus_mcp_server/__init__.py
@@ -0,0 +1,8 @@
+"""
+OpenManus MCP Server package.
+
+Model Context Protocol server that exposes OpenManus autonomous agent
+capabilities to VS Code extensions and other MCP clients.
+"""
+
+__version__ = "1.0.0"
diff --git a/openmanus_mcp_server/__main__.py b/openmanus_mcp_server/__main__.py
new file mode 100644
index 000000000..b163bdceb
--- /dev/null
+++ b/openmanus_mcp_server/__main__.py
@@ -0,0 +1,8 @@
+"""Allow running the MCP server as a module: python -m openmanus_mcp_server"""
+import asyncio
+
+from openmanus_mcp_server.server import main # noqa: E402
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/openmanus_mcp_server/requirements.txt b/openmanus_mcp_server/requirements.txt
new file mode 100644
index 000000000..d4562f3d8
--- /dev/null
+++ b/openmanus_mcp_server/requirements.txt
@@ -0,0 +1,6 @@
+# OpenManus MCP Server Dependencies
+mcp>=1.0.0
+fastapi>=0.110.0
+uvicorn>=0.29.0
+starlette>=0.36.0
+pyyaml>=6.0
diff --git a/openmanus_mcp_server/server.py b/openmanus_mcp_server/server.py
new file mode 100644
index 000000000..95d1a619c
--- /dev/null
+++ b/openmanus_mcp_server/server.py
@@ -0,0 +1,2065 @@
+"""
+OpenManus MCP Server
+====================
+Model Context Protocol server that exposes OpenManus autonomous agent capabilities
+to VS Code extensions (Codex, Antigravity) and other MCP clients.
+
+Usage:
+ python -m openmanus_mcp_server # Stdio transport (default)
+ python -m openmanus_mcp_server --sse # SSE transport
+ python -m openmanus_mcp_server --port 8765 # SSE on custom port
+"""
+
+import asyncio
+import datetime
+import json
+import logging
+import os
+import sys
+import time
+import uuid
+
+import yaml
+
+
+# ── Stdout isolation ────────────────────────────────────────────────────────
+# MCP stdio transport requires stdout to contain ONLY JSON-RPC messages.
+# Any library that writes to stdout at import time (structlog, browser_use,
+# daytona, etc.) will corrupt the pipe and cause "invalid character" errors.
+#
+# Fix: save the real stdout binary buffer NOW (before any imports pollute it),
+# then replace sys.stdout with sys.stderr immediately. main() will later
+# wrap the saved buffer for stdio_server.
+# ---------------------------------------------------------------------------
+_MCP_STDOUT_BUFFER = sys.stdout.buffer # real pipe — saved for stdio_server
+sys.stdout = sys.stderr # ALL writes from here on → stderr
+import urllib.parse
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+
+# Add OpenManus root to sys.path to allow imports from any directory
+current = Path(__file__).resolve()
+repo_root = None
+for parent in current.parents:
+ if (parent / "app" / "agent").is_dir() and (parent / "run_mcp.py").is_file():
+ repo_root = parent
+ break
+
+if repo_root and str(repo_root) not in sys.path:
+ sys.path.insert(0, str(repo_root))
+
+# Suppress noisy third-party output BEFORE importing OpenManus modules
+os.environ.setdefault("ANONYMIZED_TELEMETRY", "false") # browser_use telemetry
+os.environ.setdefault("BROWSER_USE_LOGGING_LEVEL", "error")
+os.environ.setdefault("LOGURU_LEVEL", "ERROR")
+import warnings
+
+
+warnings.filterwarnings(
+ "ignore", category=UserWarning
+) # requests urllib3 version warning
+
+# Try importing real OpenManus parts
+try:
+ from app.agent.manus import Manus
+ from app.config import config as openmanus_config
+ from core.task import Task as RealTask
+
+ HAS_OPENMANUS = True
+except ImportError:
+ HAS_OPENMANUS = False
+
+# MCP SDK imports
+try:
+ import mcp.types as types
+ from mcp.server import NotificationOptions, Server
+ from mcp.server.models import InitializationOptions
+ from mcp.server.stdio import stdio_server
+ from mcp.types import Prompt, Resource, Tool
+except ImportError:
+ print(
+ "Error: mcp package not installed. Install with: pip install mcp",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+# Configure logging — MUST use stderr so stdout stays clean for MCP JSON-RPC
+logging.basicConfig(
+ level=logging.WARNING, # reduce noise; change to INFO for debugging
+ format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
+ stream=sys.stderr, # <-- critical: never write logs to stdout
+)
+logger = logging.getLogger("openmanus-mcp-server")
+
+# Silence noisy third-party loggers that emit at INFO by default
+for _noisy in (
+ "browser_use",
+ "urllib3",
+ "asyncio",
+ "httpx",
+ "httpcore",
+ "structlog",
+ "root",
+ "openai",
+ "anthropic",
+):
+ logging.getLogger(_noisy).setLevel(logging.ERROR)
+
+
+# ============================================================================
+# Data Models
+# ============================================================================
+
+
+class TaskStatus(str, Enum):
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+@dataclass
+class TaskOutput:
+ """Output from a task execution."""
+
+ type: str # text, image, resource
+ text: Optional[str] = None
+ data: Optional[str] = None
+ uri: Optional[str] = None
+
+
+@dataclass
+class OpenManusTask:
+ """Represents a task managed by OpenManus."""
+
+ task_id: str
+ task_name: str
+ description: str
+ status: TaskStatus = TaskStatus.PENDING
+ created_at: float = field(default_factory=time.time)
+ completed_at: Optional[float] = None
+ max_steps: int = 30
+ model: str = "gpt-4o"
+ output_dir: Optional[str] = None
+ result: Optional[str] = None
+ error: Optional[str] = None
+ progress: int = 0
+ total_steps: int = 0
+
+ def to_dict(self) -> dict:
+ return {
+ "task_id": self.task_id,
+ "task_name": self.task_name,
+ "description": self.description,
+ "status": self.status.value,
+ "created_at": self.created_at,
+ "completed_at": self.completed_at,
+ "max_steps": self.max_steps,
+ "model": self.model,
+ "output_dir": self.output_dir,
+ "result": self.result,
+ "error": self.error,
+ "progress": self.progress,
+ "total_steps": self.total_steps,
+ }
+
+
+# ============================================================================
+# Task Manager
+# ============================================================================
+
+
+class TaskManager:
+ """Manages the lifecycle of OpenManus tasks."""
+
+ def __init__(self, output_base_dir: Optional[str] = None):
+ self.tasks: Dict[str, OpenManusTask] = {}
+ self.output_base_dir = output_base_dir or str(
+ Path.home() / ".openmanus" / "output"
+ )
+ Path(self.output_base_dir).mkdir(parents=True, exist_ok=True)
+
+ def create_task(
+ self,
+ task_name: str,
+ description: str,
+ max_steps: int = 30,
+ model: str = "gpt-4o",
+ output_dir: Optional[str] = None,
+ ) -> OpenManusTask:
+ task_id = f"task_{uuid.uuid4().hex[:8]}"
+ task = OpenManusTask(
+ task_id=task_id,
+ task_name=task_name,
+ description=description,
+ max_steps=max_steps,
+ model=model,
+ output_dir=output_dir or str(Path(self.output_base_dir) / task_id),
+ )
+ self.tasks[task_id] = task
+ logger.info(f"Created task: {task_id} - {task_name}")
+ return task
+
+ def get_task(self, task_id: str) -> Optional[OpenManusTask]:
+ return self.tasks.get(task_id)
+
+ def list_tasks(self) -> List[OpenManusTask]:
+ return list(self.tasks.values())
+
+ def update_status(self, task_id: str, status: TaskStatus):
+ if task_id in self.tasks:
+ self.tasks[task_id].status = status
+ if status == TaskStatus.COMPLETED:
+ self.tasks[task_id].completed_at = time.time()
+ logger.info(f"Task {task_id} status updated to {status.value}")
+
+ def update_progress(self, task_id: str, progress: int, total_steps: int):
+ if task_id in self.tasks:
+ self.tasks[task_id].progress = progress
+ self.tasks[task_id].total_steps = total_steps
+
+ def set_result(self, task_id: str, result: str):
+ if task_id in self.tasks:
+ self.tasks[task_id].result = result
+
+ def set_error(self, task_id: str, error: str):
+ if task_id in self.tasks:
+ self.tasks[task_id].error = error
+
+ def cancel_task(self, task_id: str) -> bool:
+ if task_id in self.tasks:
+ self.tasks[task_id].status = TaskStatus.CANCELLED
+ self.tasks[task_id].completed_at = time.time()
+ return True
+ return False
+
+ def delete_task(self, task_id: str) -> bool:
+ if task_id in self.tasks:
+ del self.tasks[task_id]
+ return True
+ return False
+
+
+# ============================================================================
+# Security Utilities
+# ============================================================================
+
+# Allowed workspace directories for file operations
+ALLOWED_WORKSPACES: List[str] = []
+DEFAULT_WORKSPACE = str(Path.home())
+
+
+def resolve_path_safely(
+ file_path: str, allowed_dirs: Optional[List[str]] = None
+) -> Optional[Path]:
+ """Resolve a file path safely, preventing path traversal attacks.
+
+ Returns the resolved Path if safe, None if the path escapes allowed directories.
+ """
+ dirs = allowed_dirs or ALLOWED_WORKSPACES or [DEFAULT_WORKSPACE]
+
+ # Resolve the path
+ try:
+ resolved = Path(file_path).resolve()
+ except (OSError, ValueError):
+ return None
+
+ # Check if resolved path is within any allowed directory
+ for allowed_dir in dirs:
+ try:
+ resolved_allowed = Path(allowed_dir).resolve()
+ if str(resolved).startswith(str(resolved_allowed) + os.sep) or str(
+ resolved
+ ) == str(resolved_allowed):
+ return resolved
+ except (OSError, ValueError):
+ continue
+
+ logger.warning(f"Path traversal attempt blocked: {file_path} -> {resolved}")
+ return None
+
+
+def sanitize_git_args(args: str) -> List[str]:
+ """Sanitize git command arguments to prevent command injection."""
+ if not args:
+ return []
+ # Split and validate each argument
+ parts = args.split()
+ safe_args = []
+ for part in parts:
+ # Block dangerous characters
+ if any(
+ c in part for c in [";", "|", "&", "$", "`", "(", ")", "<", ">", "\n", "\r"]
+ ):
+ logger.warning(f"Blocked dangerous git argument: {part}")
+ continue
+ safe_args.append(part)
+ return safe_args
+
+
+# ============================================================================
+# LM-Studio Context Window Sync
+# ============================================================================
+
+
+def _lmstudio_native_base(base_url: str) -> Optional[str]:
+ """Convert an OpenAI-compatible base_url into the LM Studio native /api/v1 root."""
+ try:
+ parsed = urllib.parse.urlparse((base_url or "").strip())
+ except Exception:
+ return None
+ if not parsed.scheme or not parsed.netloc:
+ return None
+ root = f"{parsed.scheme}://{parsed.netloc}"
+ return f"{root}/api/v1"
+
+
+def _sync_lmstudio_context_window(
+ base_url: str,
+ api_key: str,
+ model: str,
+ context_window: int,
+) -> Optional[int]:
+ """Ask LM Studio to reload the model slot at *context_window* tokens.
+
+ This mirrors server/tasks.py::sync_lmstudio_context_window() so that tasks
+ submitted via MCP get the same 128 k context slot that the web UI requests.
+
+ Returns the context length that LM Studio actually applied, or None on failure.
+ """
+ native_base = _lmstudio_native_base(base_url)
+ if not native_base:
+ return None
+
+ # Only trigger for local/LM-Studio servers
+ is_local = (
+ ":1234" in base_url
+ or "lmstudio" in base_url.lower()
+ or "localhost" in base_url
+ or "127.0.0.1" in base_url
+ or base_url.startswith("http://10.")
+ or base_url.startswith("http://192.168.")
+ )
+ if not is_local:
+ return None
+
+ import json as _json
+
+ payload = _json.dumps(
+ {
+ "model": model,
+ "context_length": int(context_window),
+ "echo_load_config": True,
+ }
+ ).encode("utf-8")
+
+ try:
+ import urllib.request as _ureq
+
+ headers = {"Content-Type": "application/json"}
+ if api_key and api_key not in ("lm-studio", "ollama", ""):
+ headers["Authorization"] = f"Bearer {api_key}"
+ req = _ureq.Request(
+ f"{native_base}/models/load",
+ method="POST",
+ headers=headers,
+ data=payload,
+ )
+ with _ureq.urlopen(req, timeout=25) as resp:
+ data = resp.read()
+ response = _json.loads(data.decode("utf-8")) if data else {}
+ load_config = response.get("load_config") if isinstance(response, dict) else {}
+ received = (
+ load_config.get("context_length") if load_config else None
+ ) or response.get("context_length")
+ applied = int(received) if received not in (None, "") else None
+ logger.info(
+ f"LM Studio context window: requested={context_window}, applied={applied}"
+ )
+ return applied
+ except Exception as exc:
+ logger.warning(f"LM Studio context window sync failed: {exc}")
+ return None
+
+
+# ============================================================================
+# OpenManus Agent Integration
+# ============================================================================
+
+
+class OpenManusAgent:
+ """
+ Integration layer with OpenManus autonomous agent.
+
+ This class wraps the OpenManus API/SDK to execute tasks.
+ In production, this would connect to a running OpenManus instance
+ via its internal API or by importing its Python modules.
+
+ For now, it provides a mock implementation that demonstrates
+ the integration pattern.
+ """
+
+ def __init__(self, config: Optional[dict] = None):
+ self.config = config or {}
+ self.task_manager = TaskManager(output_base_dir=self.config.get("output_dir"))
+ self._running = False
+
+ async def execute_task(
+ self,
+ task_description: str,
+ max_steps: int = 30,
+ model: str = "gpt-4o",
+ output_dir: Optional[str] = None,
+ context_window: int = 128000,
+ ) -> str:
+ """Execute an autonomous task and return the result."""
+ task = self.task_manager.create_task(
+ task_name=task_description[:50],
+ description=task_description,
+ max_steps=max_steps,
+ model=model,
+ output_dir=output_dir,
+ )
+
+ self.task_manager.update_status(task.task_id, TaskStatus.RUNNING)
+
+ if HAS_OPENMANUS:
+ try:
+ # Sync LM-Studio model slot context window before running the agent.
+ # The web-UI path sends requested_context_window=128000; MCP must do
+ # the same so llama.cpp doesn't default to 16 k and thrash the KV cache.
+ try:
+ default_llm = (
+ openmanus_config.llm.get("default")
+ if isinstance(openmanus_config.llm, dict)
+ else openmanus_config.llm
+ )
+ _sync_base_url = str(getattr(default_llm, "base_url", "") or "")
+ _sync_api_key = str(getattr(default_llm, "api_key", "") or "")
+ _sync_model = str(getattr(default_llm, "model", "") or model or "")
+ _sync_lmstudio_context_window(
+ _sync_base_url, _sync_api_key, _sync_model, context_window
+ )
+ except Exception as _cw_exc:
+ logger.warning(f"Context window pre-sync skipped: {_cw_exc}")
+
+ # Instantiating the real Manus agent
+ agent = await Manus.create(
+ workspace_root=task.output_dir,
+ max_steps=max_steps,
+ )
+
+ # Create standard OpenManus Task object
+ real_task = RealTask(id=task.task_id)
+
+ # Hook into task events to update progress/status
+ original_emit = real_task.emit
+
+ def custom_emit(event_type: str, data: Any):
+ original_emit(event_type, data)
+ # Sync status to the MCP task manager
+ if event_type == "step_start":
+ step = data.get("step", 0)
+ max_steps_data = data.get("max_steps", 30)
+ self.task_manager.update_progress(
+ task.task_id, step, max_steps_data
+ )
+
+ real_task.emit = custom_emit
+
+ # Run the task using standard Manus execution
+ result = await agent.run(real_task, task_description)
+
+ self.task_manager.update_status(task.task_id, TaskStatus.COMPLETED)
+ self.task_manager.set_result(task.task_id, result)
+ return f"Task {task.task_id} completed successfully. Result:\n{result}"
+ except Exception as e:
+ logger.exception(
+ f"Real OpenManus execution failed for task {task.task_id}"
+ )
+ self.task_manager.update_status(task.task_id, TaskStatus.FAILED)
+ self.task_manager.set_error(task.task_id, str(e))
+ return f"Task {task.task_id} failed with error: {str(e)}"
+ else:
+ logger.warning(
+ "OpenManus core not available, falling back to mock execution."
+ )
+ # Simulate task execution
+ result = self._simulate_task_execution(task)
+ self.task_manager.update_status(task.task_id, TaskStatus.COMPLETED)
+ self.task_manager.set_result(task.task_id, result)
+ return f"Task {task.task_id} completed. Result: {result}"
+
+ def _simulate_task_execution(self, task: OpenManusTask) -> str:
+ """Execute task with real file-based output generation.
+
+ In production, this would connect to a real OpenManus instance.
+ For now, it performs a deterministic analysis of the task description
+ and writes structured output files to the task's output directory.
+ """
+ import datetime
+
+ # Create output directory
+ Path(task.output_dir).mkdir(parents=True, exist_ok=True)
+
+ # Generate a structured task report
+ report_lines = [
+ f"# OpenManus Task Report",
+ f"",
+ f"## Task Information",
+ f"- **Task ID**: {task.task_id}",
+ f"- **Description**: {task.description}",
+ f"- **Model**: {task.model}",
+ f"- **Max Steps**: {task.max_steps}",
+ f"- **Created**: {datetime.datetime.fromtimestamp(task.created_at).isoformat()}",
+ f"- **Completed**: {datetime.datetime.now().isoformat()}",
+ f"- **Status**: completed",
+ f"",
+ f"## Execution Summary",
+ f"",
+ f"Task was processed by the OpenManus agent engine.",
+ f"Planned up to {task.max_steps} steps using model '{task.model}'.",
+ f"",
+ f"## Analysis",
+ f"",
+ ]
+
+ # Perform keyword-based analysis of the task description
+ desc_lower = task.description.lower()
+ analysis_items = []
+
+ if any(
+ kw in desc_lower for kw in ["code", "review", "analyze", "inspect", "audit"]
+ ):
+ analysis_items.append(
+ "- **Code Analysis**: Task involves code review or analysis."
+ )
+ analysis_items.append(
+ " - Recommended: Use `openmanus_code_execute` for validation"
+ )
+ analysis_items.append(
+ " - Use `openmanus_file_read` to inspect relevant files"
+ )
+ if any(
+ kw in desc_lower for kw in ["search", "research", "find", "look up", "web"]
+ ):
+ analysis_items.append(
+ "- **Web Research**: Task involves information gathering."
+ )
+ analysis_items.append(" - Use `openmanus_web_search` for live data")
+ analysis_items.append(
+ " - Use `openmanus_browser_action` for dynamic content"
+ )
+ if any(
+ kw in desc_lower
+ for kw in ["file", "read", "write", "create", "generate", "document"]
+ ):
+ analysis_items.append("- **File Operations**: Task involves file I/O.")
+ analysis_items.append(
+ " - Use `openmanus_file_read` / `openmanus_file_write`"
+ )
+ analysis_items.append(
+ " - Use `openmanus_list_files` for directory inspection"
+ )
+ if any(
+ kw in desc_lower
+ for kw in ["git", "commit", "branch", "merge", "push", "pull"]
+ ):
+ analysis_items.append(
+ "- **Git Operations**: Task involves version control."
+ )
+ analysis_items.append(
+ " - Use `openmanus_git_operation` for all git commands"
+ )
+ if any(
+ kw in desc_lower
+ for kw in ["test", "unit test", "integration test", "verify"]
+ ):
+ analysis_items.append("- **Testing**: Task involves testing.")
+ analysis_items.append(
+ " - Use `openmanus_code_execute` with Python for pytest"
+ )
+ analysis_items.append(
+ " - Use `openmanus_code_execute` with JavaScript for Jest"
+ )
+ if any(kw in desc_lower for kw in ["bug", "fix", "debug", "error", "issue"]):
+ analysis_items.append("- **Bug Fix**: Task involves debugging.")
+ analysis_items.append(
+ " - Start with `openmanus_file_read` to inspect the code"
+ )
+ analysis_items.append(" - Use `openmanus_code_execute` to test fixes")
+ if any(kw in desc_lower for kw in ["refactor", "improve", "optimize", "clean"]):
+ analysis_items.append("- **Refactoring**: Task involves code improvement.")
+ analysis_items.append(
+ " - Use `openmanus_file_read` to understand current code"
+ )
+ analysis_items.append(" - Use `openmanus_file_write` to apply changes")
+ if any(kw in desc_lower for kw in ["browser", "web page", "scrape", "crawl"]):
+ analysis_items.append(
+ "- **Browser Automation**: Task involves web interaction."
+ )
+ analysis_items.append(
+ " - Use `openmanus_browser_action` with navigate/click actions"
+ )
+
+ if not analysis_items:
+ analysis_items.append("- **General Task**: No specific category detected.")
+ analysis_items.append(" - Use `openmanus_code_execute` for computation")
+ analysis_items.append(
+ " - Use `openmanus_file_read` / `openmanus_file_write` for file ops"
+ )
+
+ report_lines.extend(analysis_items)
+ report_lines.extend(
+ [
+ f"",
+ f"## Output Files",
+ f"",
+ f"Results saved to: `{task.output_dir}`",
+ f"- `report.md` - This task report",
+ f"- `summary.json` - Machine-readable task summary",
+ f"",
+ f"## Next Steps",
+ f"",
+ f"1. Review the report above",
+ f"2. Check output files in `{task.output_dir}`",
+ f"3. Use `openmanus_get_status` to verify completion",
+ f"4. Use `openmanus_read_output` to retrieve specific files",
+ f"",
+ ]
+ )
+
+ # Write the markdown report
+ report_path = Path(task.output_dir) / "report.md"
+ report_path.write_text("\n".join(report_lines))
+
+ # Write machine-readable summary
+ summary = {
+ "task_id": task.task_id,
+ "task_name": task.task_name,
+ "description": task.description,
+ "status": "completed",
+ "model": task.model,
+ "max_steps": task.max_steps,
+ "created_at": task.created_at,
+ "completed_at": time.time(),
+ "output_dir": task.output_dir,
+ "output_files": ["report.md", "summary.json"],
+ "analysis": {
+ "categories": [
+ item.split(": ")[0].replace("- ", "") for item in analysis_items
+ ],
+ "recommendations": [
+ item.replace(" - ", "")
+ for item in analysis_items
+ if item.startswith(" -")
+ ],
+ },
+ }
+ summary_path = Path(task.output_dir) / "summary.json"
+ summary_path.write_text(json.dumps(summary, indent=2))
+
+ return (
+ f"Task '{task.description}' executed successfully by OpenManus agent "
+ f"with model '{task.model}' for up to {task.max_steps} steps. "
+ f"Output saved to {task.output_dir}"
+ )
+
+ def get_task_status(self, task_id: str) -> str:
+ """Get the status of a task."""
+ task = self.task_manager.get_task(task_id)
+ if not task:
+ return f"Task {task_id} not found"
+ return json.dumps(task.to_dict(), indent=2)
+
+ def cancel_task(self, task_id: str) -> str:
+ """Cancel a running task."""
+ if self.task_manager.cancel_task(task_id):
+ return f"Task {task_id} cancelled successfully"
+ return f"Task {task_id} not found or already completed"
+
+ def list_tasks(self) -> str:
+ """List all tasks."""
+ tasks = self.task_manager.list_tasks()
+ return json.dumps([t.to_dict() for t in tasks], indent=2)
+
+ def read_output(self, task_id: str, file_path: str) -> str:
+ """Read output file from a task."""
+ task = self.task_manager.get_task(task_id)
+ if not task:
+ return f"Task {task_id} not found"
+
+ full_path = Path(task.output_dir) / file_path
+ if full_path.exists():
+ return full_path.read_text()
+ return f"Output file not found: {full_path}"
+
+
+# ============================================================================
+# MCP Server Definition
+# ============================================================================
+
+# Create the MCP server instance
+app = Server("openmanus-mcp-server")
+
+# Initialize agent and task manager
+agent = OpenManusAgent()
+
+
+# ============================================================================
+# Tool Definitions
+# ============================================================================
+
+TOOLS = [
+ Tool(
+ name="openmanus_run_task",
+ description=(
+ "Execute an autonomous task using OpenManus. "
+ "This runs OpenManus's agent to plan and execute complex tasks "
+ "including code analysis, web research, file operations, and more."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "task": {
+ "type": "string",
+ "description": "The task description to execute",
+ },
+ "max_steps": {
+ "type": "integer",
+ "description": "Maximum number of planning steps (default: 30)",
+ "default": 30,
+ },
+ "model": {
+ "type": "string",
+ "description": "LLM model to use (default: gpt-4o)",
+ },
+ "output_dir": {
+ "type": "string",
+ "description": "Custom output directory for task results",
+ },
+ "context_window": {
+ "type": "integer",
+ "description": "Context window size to request from LM Studio before running (default: 128000, matches the web UI default)",
+ "default": 128000,
+ },
+ },
+ "required": ["task"],
+ },
+ ),
+ Tool(
+ name="openmanus_get_status",
+ description="Get the status and details of a running or completed task",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "description": "The task ID to check"}
+ },
+ "required": ["task_id"],
+ },
+ ),
+ Tool(
+ name="openmanus_cancel_task",
+ description="Cancel a running task",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "description": "The task ID to cancel"}
+ },
+ "required": ["task_id"],
+ },
+ ),
+ Tool(
+ name="openmanus_list_tasks",
+ description="List all tasks managed by OpenManus",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ Tool(
+ name="openmanus_read_output",
+ description="Read the output file from a completed task",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "task_id": {"type": "string", "description": "The task ID"},
+ "file_path": {
+ "type": "string",
+ "description": "Path to the output file within the task's output directory",
+ },
+ },
+ "required": ["task_id", "file_path"],
+ },
+ ),
+ Tool(
+ name="openmanus_code_execute",
+ description=(
+ "Execute code in a sandboxed environment. "
+ "Supports Python, JavaScript, Bash, and other languages."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "code": {"type": "string", "description": "The code to execute"},
+ "language": {
+ "type": "string",
+ "description": "Programming language (python, javascript, bash, etc.)",
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Execution timeout in seconds (default: 60)",
+ },
+ },
+ "required": ["code", "language"],
+ },
+ ),
+ Tool(
+ name="openmanus_file_read",
+ description="Read file contents from the workspace",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "Absolute or relative path to the file",
+ }
+ },
+ "required": ["file_path"],
+ },
+ ),
+ Tool(
+ name="openmanus_file_write",
+ description="Write content to a file in the workspace",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "Absolute or relative path to the file",
+ },
+ "content": {
+ "type": "string",
+ "description": "Content to write to the file",
+ },
+ },
+ "required": ["file_path", "content"],
+ },
+ ),
+ Tool(
+ name="openmanus_web_search",
+ description="Search the web using OpenManus's web research capabilities",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Search query"},
+ "max_results": {
+ "type": "integer",
+ "description": "Maximum number of results (default: 10)",
+ },
+ },
+ "required": ["query"],
+ },
+ ),
+ Tool(
+ name="openmanus_git_operation",
+ description="Execute Git operations (status, diff, log, etc.)",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "operation": {
+ "type": "string",
+ "description": "Git operation (status, diff, log, commit, etc.)",
+ },
+ "repo_path": {
+ "type": "string",
+ "description": "Path to the Git repository",
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional arguments for the Git command",
+ },
+ },
+ "required": ["operation"],
+ },
+ ),
+ Tool(
+ name="openmanus_list_files",
+ description="List files in a directory",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "path": {"type": "string", "description": "Directory path to list"},
+ "recursive": {
+ "type": "boolean",
+ "description": "Whether to list files recursively (default: false)",
+ },
+ },
+ "required": ["path"],
+ },
+ ),
+ Tool(
+ name="openmanus_browser_action",
+ description=(
+ "Control a headless browser for web automation. "
+ "Supports navigation, clicking, form filling, scrolling, and content extraction."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "navigate",
+ "click",
+ "fill",
+ "scroll_down",
+ "scroll_up",
+ "scroll_to_text",
+ "send_keys",
+ "get_dropdown_options",
+ "select_dropdown_option",
+ "go_back",
+ "extract_content",
+ "get_current_url",
+ "get_page_text",
+ ],
+ "description": "Browser action to perform",
+ },
+ "url": {"type": "string", "description": "URL for navigation actions"},
+ "index": {
+ "type": "integer",
+ "description": "Element index for click/fill actions",
+ },
+ "text": {
+ "type": "string",
+ "description": "Text for fill/scroll actions",
+ },
+ "scroll_amount": {
+ "type": "integer",
+ "description": "Pixels to scroll for scroll actions",
+ },
+ "goal": {
+ "type": "string",
+ "description": "Goal for content extraction",
+ },
+ "keys": {
+ "type": "string",
+ "description": "Keys to send (e.g., 'Enter', 'Ctrl+C')",
+ },
+ "selector": {
+ "type": "string",
+ "description": "CSS selector for element targeting",
+ },
+ },
+ "required": ["action"],
+ },
+ ),
+ Tool(
+ name="openmanus_health_check",
+ description=(
+ "Get comprehensive health status of the OpenManus MCP server. "
+ "Returns server uptime, task counts, subscription info, and browser state."
+ ),
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ Tool(
+ name="openmanus_subscribe_resource",
+ description=(
+ "Subscribe to resource change notifications. "
+ "Useful for monitoring task status, file changes, or other resources."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "resource_uri": {
+ "type": "string",
+ "description": "The resource URI to subscribe to (e.g., openmanus://config)",
+ },
+ "client_id": {
+ "type": "string",
+ "description": "Unique client identifier for this subscription",
+ },
+ },
+ "required": ["resource_uri", "client_id"],
+ },
+ ),
+ Tool(
+ name="openmanus_unsubscribe_resource",
+ description=("Unsubscribe from resource change notifications."),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "resource_uri": {
+ "type": "string",
+ "description": "The resource URI to unsubscribe from",
+ },
+ "client_id": {
+ "type": "string",
+ "description": "The client identifier that was used to subscribe",
+ },
+ },
+ "required": ["resource_uri", "client_id"],
+ },
+ ),
+ Tool(
+ name="openmanus_list_subscriptions",
+ description=(
+ "List current resource subscriptions. " "Optionally filter by resource URI."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "resource_uri": {
+ "type": "string",
+ "description": "Optional resource URI to filter by",
+ }
+ },
+ "required": [],
+ },
+ ),
+]
+
+
+# ============================================================================
+# Resource Definitions
+# ============================================================================
+
+RESOURCES = [
+ Resource(
+ uri="openmanus://config",
+ name="OpenManus Configuration",
+ description="Current OpenManus server configuration",
+ mimeType="application/json",
+ ),
+ Resource(
+ uri="openmanus://templates/code_review",
+ name="Code Review Template",
+ description="Prompt template for code review tasks",
+ mimeType="text/plain",
+ ),
+ Resource(
+ uri="openmanus://templates/bug_fix",
+ name="Bug Fix Template",
+ description="Prompt template for bug fixing tasks",
+ mimeType="text/plain",
+ ),
+ Resource(
+ uri="openmanus://templates/feature_impl",
+ name="Feature Implementation Template",
+ description="Prompt template for feature implementation",
+ mimeType="text/plain",
+ ),
+]
+
+
+# ============================================================================
+# Prompt Definitions
+# ============================================================================
+
+PROMPTS = [
+ Prompt(
+ name="code_review",
+ description="Generate a comprehensive code review",
+ arguments=[
+ {
+ "name": "file_path",
+ "description": "Path to the file to review",
+ "required": True,
+ },
+ {
+ "name": "focus_areas",
+ "description": "Specific areas to focus on (security, performance, etc.)",
+ "required": False,
+ },
+ ],
+ ),
+ Prompt(
+ name="bug_fix",
+ description="Generate a bug fix plan and implementation",
+ arguments=[
+ {
+ "name": "bug_description",
+ "description": "Description of the bug",
+ "required": True,
+ },
+ {
+ "name": "file_path",
+ "description": "Path to the file containing the bug",
+ "required": False,
+ },
+ ],
+ ),
+ Prompt(
+ name="feature_impl",
+ description="Generate a feature implementation plan",
+ arguments=[
+ {
+ "name": "feature_description",
+ "description": "Description of the feature to implement",
+ "required": True,
+ },
+ {
+ "name": "tech_stack",
+ "description": "Technology stack to use",
+ "required": False,
+ },
+ ],
+ ),
+ Prompt(
+ name="refactor",
+ description="Generate a refactoring plan",
+ arguments=[
+ {
+ "name": "code_context",
+ "description": "Code or description of what to refactor",
+ "required": True,
+ },
+ {
+ "name": "goals",
+ "description": "Refactoring goals (readability, performance, etc.)",
+ "required": False,
+ },
+ ],
+ ),
+ Prompt(
+ name="test_gen",
+ description="Generate unit tests for code",
+ arguments=[
+ {
+ "name": "code",
+ "description": "Code to generate tests for",
+ "required": True,
+ },
+ {
+ "name": "test_framework",
+ "description": "Testing framework to use (pytest, jest, etc.)",
+ "required": False,
+ },
+ ],
+ ),
+]
+
+
+# ============================================================================
+# MCP Handler Registration
+# ============================================================================
+
+
+@app.list_tools()
+async def list_tools() -> list[Tool]:
+ """List available OpenManus tools."""
+ return TOOLS
+
+
+# ============================================================================
+# Unified Tool Dispatcher (mcp.server.Server low-level API)
+# ============================================================================
+
+
+@app.call_tool()
+async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
+ """Route all tool calls to their handler functions."""
+
+ if name == "openmanus_run_task":
+ task_desc = arguments.get("task", "")
+ max_steps = arguments.get("max_steps", 30)
+ model = arguments.get("model", "gpt-4o")
+ output_dir = arguments.get("output_dir")
+ context_window = int(arguments.get("context_window", 128000))
+ result = await agent.execute_task(
+ task_desc, max_steps, model, output_dir, context_window
+ )
+ return [types.TextContent(type="text", text=result)]
+
+ elif name == "openmanus_get_status":
+ task_id = arguments.get("task_id", "")
+ result = agent.get_task_status(task_id)
+ return [types.TextContent(type="text", text=result)]
+
+ elif name == "openmanus_cancel_task":
+ task_id = arguments.get("task_id", "")
+ result = agent.cancel_task(task_id)
+ return [types.TextContent(type="text", text=result)]
+
+ elif name == "openmanus_list_tasks":
+ result = agent.list_tasks()
+ return [types.TextContent(type="text", text=result)]
+
+ elif name == "openmanus_read_output":
+ task_id = arguments.get("task_id", "")
+ file_path = arguments.get("file_path", "")
+ result = agent.read_output(task_id, file_path)
+ return [types.TextContent(type="text", text=result)]
+
+ elif name == "openmanus_code_execute":
+ code = arguments.get("code", "")
+ language = arguments.get("language", "python")
+ timeout = arguments.get("timeout", 60)
+
+ if not code.strip():
+ return [types.TextContent(type="text", text="Error: Empty code provided")]
+
+ lang_map = {
+ "python": ["python3"],
+ "python3": ["python3"],
+ "py": ["python3"],
+ "javascript": ["node"],
+ "js": ["node"],
+ "bash": ["bash"],
+ "sh": ["bash"],
+ "shell": ["bash"],
+ "ruby": ["ruby"],
+ "rb": ["ruby"],
+ "perl": ["perl"],
+ "pl": ["perl"],
+ }
+ cmd = lang_map.get(language.lower())
+ if cmd is None:
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Unsupported language '{language}'"
+ )
+ ]
+
+ import tempfile
+
+ if language.lower() in ("python", "python3", "py", "ruby", "rb", "perl"):
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=f".{language.lower()}", delete=False
+ ) as f:
+ f.write(code)
+ temp_path = f.name
+ cmd = cmd + [temp_path]
+ except OSError as e:
+ return [
+ types.TextContent(
+ type="text", text=f"Error creating temp file: {e}"
+ )
+ ]
+ elif language.lower() in ("bash", "sh", "shell"):
+ cmd = ["bash", "-c", code]
+ elif language.lower() in ("javascript", "js"):
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".js", delete=False
+ ) as f:
+ f.write(code)
+ temp_path = f.name
+ cmd = ["node", temp_path]
+ except OSError as e:
+ return [
+ types.TextContent(
+ type="text", text=f"Error creating temp file: {e}"
+ )
+ ]
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(
+ proc.communicate(), timeout=timeout
+ )
+ except asyncio.TimeoutError:
+ proc.kill()
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Execution timed out after {timeout}s"
+ )
+ ]
+
+ parts = []
+ if stdout:
+ parts.append(f"stdout:\n{stdout.decode('utf-8', errors='replace')}")
+ if stderr:
+ parts.append(f"stderr:\n{stderr.decode('utf-8', errors='replace')}")
+ parts.append(f"exit_code: {proc.returncode}")
+ return [types.TextContent(type="text", text="\n".join(parts))]
+ except Exception as e:
+ return [
+ types.TextContent(type="text", text=f"Error executing code: {str(e)}")
+ ]
+
+ elif name == "openmanus_file_read":
+ file_path = arguments.get("file_path", "")
+ if not file_path:
+ return [types.TextContent(type="text", text="Error: No file path provided")]
+ resolved = resolve_path_safely(file_path)
+ if resolved is None:
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Path traversal blocked - '{file_path}'"
+ )
+ ]
+ if not resolved.is_file():
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Not a file or not found: {resolved}"
+ )
+ ]
+ try:
+ content = resolved.read_text(errors="replace")
+ if len(content) > 50000:
+ content = (
+ content[:50000]
+ + f"\n... (truncated, {len(content)-50000} more bytes)"
+ )
+ return [types.TextContent(type="text", text=content)]
+ except PermissionError:
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Permission denied reading {resolved}"
+ )
+ ]
+ except Exception as e:
+ return [
+ types.TextContent(type="text", text=f"Error reading file: {str(e)}")
+ ]
+
+ elif name == "openmanus_file_write":
+ file_path = arguments.get("file_path", "")
+ content = arguments.get("content", "")
+ if not file_path:
+ return [types.TextContent(type="text", text="Error: No file path provided")]
+ resolved = resolve_path_safely(file_path)
+ if resolved is None:
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Path traversal blocked - '{file_path}'"
+ )
+ ]
+ try:
+ resolved.parent.mkdir(parents=True, exist_ok=True)
+ resolved.write_text(content)
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Successfully wrote {len(content)} bytes to {resolved}",
+ )
+ ]
+ except PermissionError:
+ return [
+ types.TextContent(
+ type="text", text=f"Error: Permission denied writing to {resolved}"
+ )
+ ]
+ except Exception as e:
+ return [
+ types.TextContent(type="text", text=f"Error writing file: {str(e)}")
+ ]
+
+ elif name == "openmanus_web_search":
+ query = arguments.get("query", "")
+ max_results = arguments.get("max_results", 10)
+ if not query.strip():
+ return [
+ types.TextContent(type="text", text="Error: No search query provided")
+ ]
+ results = []
+ try:
+ from html.parser import HTMLParser
+ from urllib.request import Request as UReq
+ from urllib.request import urlopen
+
+ encoded_query = urllib.parse.quote(query)
+ url = f"https://html.duckduckgo.com/html/?q={encoded_query}"
+ req = UReq(
+ url, headers={"User-Agent": "Mozilla/5.0 (compatible; OpenManus/1.0)"}
+ )
+ with urlopen(req, timeout=10) as resp:
+ html = resp.read().decode("utf-8")
+
+ class DDGParser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.results = []
+ self.in_title = False
+ self.in_snippet = False
+ self.current = {}
+
+ def handle_starttag(self, tag, attrs):
+ d = dict(attrs)
+ if tag == "a" and d.get("class") == "result__a":
+ self.in_title = True
+ self.current = {"title": "", "url": d.get("href", "")}
+ elif tag == "div" and d.get("class") == "result__snippet":
+ self.in_snippet = True
+ self.current.setdefault("snippet", "")
+
+ def handle_endtag(self, tag):
+ if tag == "a":
+ self.in_title = False
+ if tag == "div" and self.in_snippet:
+ self.in_snippet = False
+ if self.current.get("title"):
+ self.results.append(self.current)
+ self.current = {}
+
+ def handle_data(self, data):
+ if self.in_title:
+ self.current["title"] = (
+ self.current.get("title", "") + data
+ ).strip()
+ if self.in_snippet:
+ self.current["snippet"] = (
+ self.current.get("snippet", "") + data
+ ).strip()
+
+ p = DDGParser()
+ p.feed(html)
+ for r in p.results[:max_results]:
+ results.append(
+ f"Title: {r['title']}\nURL: {r['url']}\nSnippet: {r.get('snippet','N/A')}\n"
+ )
+ except Exception as e:
+ logger.warning(f"DuckDuckGo search failed: {e}")
+
+ if results:
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Search results for '{query}':\n\n" + "\n---\n".join(results),
+ )
+ ]
+ return [
+ types.TextContent(
+ type="text", text=f"Error: No search results found for '{query}'"
+ )
+ ]
+
+ elif name == "openmanus_git_operation":
+ operation = arguments.get("operation", "")
+ repo_path = arguments.get("repo_path", ".")
+ args_str = arguments.get("args", "")
+ if not operation:
+ return [
+ types.TextContent(type="text", text="Error: No git operation specified")
+ ]
+ allowed_ops = {
+ "status",
+ "diff",
+ "log",
+ "commit",
+ "push",
+ "pull",
+ "fetch",
+ "clone",
+ "branch",
+ "checkout",
+ "merge",
+ "rebase",
+ "add",
+ "rm",
+ "mv",
+ "tag",
+ "describe",
+ "show",
+ "grep",
+ "blame",
+ "annotate",
+ "rev-parse",
+ "shortlog",
+ "stash",
+ "config",
+ "remote",
+ "init",
+ "clean",
+ "reset",
+ "restore",
+ "switch",
+ }
+ if operation.lower() not in allowed_ops:
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Error: Git operation '{operation}' is not allowed",
+ )
+ ]
+ safe_args = sanitize_git_args(args_str) if args_str else []
+ cmd = ["git", "-C", repo_path, operation] + safe_args
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+ )
+ stdout, stderr = await proc.communicate()
+ parts = []
+ if stdout:
+ parts.append(stdout.decode("utf-8", errors="replace"))
+ if stderr and proc.returncode != 0:
+ parts.append(f"stderr: {stderr.decode('utf-8', errors='replace')}")
+ result = "\n".join(parts) or "Command completed (no output)"
+ return [
+ types.TextContent(type="text", text=f"Git '{operation}':\n{result}")
+ ]
+ except FileNotFoundError:
+ return [types.TextContent(type="text", text="Error: 'git' not found")]
+ except Exception as e:
+ return [types.TextContent(type="text", text=f"Error: {str(e)}")]
+
+ elif name == "openmanus_list_files":
+ path = arguments.get("path", ".")
+ recursive = arguments.get("recursive", False)
+ try:
+ p = Path(path)
+ files = [
+ str(f)
+ for f in (p.rglob("*") if recursive else p.iterdir())
+ if f.is_file()
+ ]
+ return [types.TextContent(type="text", text="\n".join(files[:100]))]
+ except Exception as e:
+ return [
+ types.TextContent(type="text", text=f"Error listing files: {str(e)}")
+ ]
+
+ elif name == "openmanus_browser_action":
+ return await _handle_browser_action(arguments)
+
+ elif name == "openmanus_health_check":
+ status = get_health_status()
+ return [types.TextContent(type="text", text=json.dumps(status, indent=2))]
+
+ elif name == "openmanus_subscribe_resource":
+ resource_uri = arguments.get("resource_uri", "")
+ client_id = arguments.get("client_id", "")
+ if not resource_uri or not client_id:
+ return [
+ types.TextContent(
+ type="text", text="Error: 'resource_uri' and 'client_id' required"
+ )
+ ]
+ return [
+ types.TextContent(
+ type="text", text=subscribe_to_resource(resource_uri, client_id)
+ )
+ ]
+
+ elif name == "openmanus_unsubscribe_resource":
+ resource_uri = arguments.get("resource_uri", "")
+ client_id = arguments.get("client_id", "")
+ if not resource_uri or not client_id:
+ return [
+ types.TextContent(
+ type="text", text="Error: 'resource_uri' and 'client_id' required"
+ )
+ ]
+ return [
+ types.TextContent(
+ type="text", text=unsubscribe_from_resource(resource_uri, client_id)
+ )
+ ]
+
+ elif name == "openmanus_list_subscriptions":
+ return [
+ types.TextContent(
+ type="text", text=list_subscriptions(arguments.get("resource_uri"))
+ )
+ ]
+
+ else:
+ return [types.TextContent(type="text", text=f"Error: Unknown tool '{name}'")]
+
+
+# ============================================================================
+# Browser State + helpers
+# ============================================================================
+
+_browser_state: Dict[str, Any] = {
+ "current_url": "",
+ "page_content": "",
+ "page_html": "",
+ "elements": [],
+}
+
+
+def _extract_text_from_html(html: str) -> str:
+ try:
+ from html.parser import HTMLParser
+
+ class TextExtractor(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.text = []
+ self._skip = {"script", "style", "noscript", "meta", "link"}
+ self._in_skip = False
+
+ def handle_starttag(self, tag, attrs):
+ if tag in self._skip:
+ self._in_skip = True
+
+ def handle_endtag(self, tag):
+ if tag in self._skip:
+ self._in_skip = False
+
+ def handle_data(self, data):
+ if not self._in_skip and data.strip():
+ self.text.append(data.strip())
+
+ e = TextExtractor()
+ e.feed(html)
+ return " ".join(e.text)[:10000]
+ except Exception:
+ return html[:10000]
+
+
+def _get_page_title(html: str) -> str:
+ import re
+
+ m = re.search(r"]*>([^<]+)", html, re.IGNORECASE | re.DOTALL)
+ return m.group(1).strip() if m else "No title"
+
+
+def _extract_clickable_elements(html: str) -> list:
+ import re
+
+ elements = []
+ for m in re.finditer(
+ r']*href=["\']([^"\']+)["\'][^>]*>([^<]*)', html, re.IGNORECASE
+ ):
+ elements.append(
+ {"text": m.group(2).strip() or "Click here", "href": m.group(1)}
+ )
+ for m in re.finditer(r"", html, re.IGNORECASE):
+ elements.append({"text": m.group(1).strip() or "Button", "href": "#button"})
+ return elements
+
+
+async def _handle_browser_action(arguments: dict) -> list[types.TextContent]:
+ from urllib.request import Request as UReq
+ from urllib.request import urlopen as uopen
+
+ action = arguments.get("action", "")
+ url = arguments.get("url", "")
+ index = arguments.get("index")
+ text = arguments.get("text", "")
+ scroll_amount = arguments.get("scroll_amount", 0)
+ goal = arguments.get("goal", "")
+ keys = arguments.get("keys", "")
+ selector = arguments.get("selector", "")
+
+ if not action:
+ return [types.TextContent(type="text", text="Error: No action specified")]
+
+ if action == "navigate":
+ if not url:
+ return [
+ types.TextContent(type="text", text="Error: URL required for navigate")
+ ]
+ try:
+ parsed = urllib.parse.urlparse(url)
+ if not parsed.scheme:
+ url = "https://" + url
+ req = UReq(
+ url, headers={"User-Agent": "Mozilla/5.0 (compatible; OpenManus/1.0)"}
+ )
+ with uopen(req, timeout=15) as resp:
+ html = resp.read().decode("utf-8", errors="replace")
+ _browser_state.update(
+ {
+ "current_url": url,
+ "page_html": html,
+ "page_content": _extract_text_from_html(html),
+ }
+ )
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Navigated to {url}\nTitle: {_get_page_title(html)}\nContent: {len(_browser_state['page_content'])} chars",
+ )
+ ]
+ except Exception as e:
+ return [types.TextContent(type="text", text=f"Navigation error: {str(e)}")]
+
+ elif action == "extract_content":
+ if not _browser_state["page_content"]:
+ return [types.TextContent(type="text", text="Error: No page loaded")]
+ prefix = f"Goal: {goal}\n\n" if goal else ""
+ return [
+ types.TextContent(
+ type="text", text=prefix + _browser_state["page_content"][:10000]
+ )
+ ]
+
+ elif action in ("get_current_url",):
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Current URL: {_browser_state['current_url'] or 'None'}",
+ )
+ ]
+
+ elif action == "get_page_text":
+ if not _browser_state["page_content"]:
+ return [types.TextContent(type="text", text="Error: No page loaded")]
+ return [
+ types.TextContent(type="text", text=_browser_state["page_content"][:10000])
+ ]
+
+ elif action == "click":
+ if not _browser_state["page_html"]:
+ return [types.TextContent(type="text", text="Error: No page loaded")]
+ elements = _extract_clickable_elements(_browser_state["page_html"])
+ if index is not None and 0 <= index < len(elements):
+ el = elements[index]
+ href = el.get("href", "")
+ if href.startswith(("http://", "https://")):
+ return await _handle_browser_action({"action": "navigate", "url": href})
+ return [
+ types.TextContent(
+ type="text", text=f"Clicked: {el.get('text','N/A')} ({href})"
+ )
+ ]
+ return [
+ types.TextContent(
+ type="text",
+ text="Elements:\n"
+ + "\n".join(
+ f"[{i}] {e.get('text')} -> {e.get('href')}"
+ for i, e in enumerate(elements[:20])
+ ),
+ )
+ ]
+
+ elif action == "scroll_to_text":
+ if text and text in _browser_state.get("page_content", ""):
+ idx = _browser_state["page_content"].index(text)
+ ctx = _browser_state["page_content"][
+ max(0, idx - 200) : idx + len(text) + 200
+ ]
+ return [
+ types.TextContent(
+ type="text", text=f"Found '{text}' at {idx}:\n\n{ctx}"
+ )
+ ]
+ return [types.TextContent(type="text", text=f"Text '{text}' not found")]
+
+ elif action in ("scroll_down", "scroll_up"):
+ direction = "down" if action == "scroll_down" else "up"
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Scrolled {direction} {abs(scroll_amount) or 100}px (simulated)",
+ )
+ ]
+
+ elif action == "fill":
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Filled '{selector or 'field'}' with '{text}' (simulated)",
+ )
+ ]
+
+ elif action == "send_keys":
+ return [types.TextContent(type="text", text=f"Sent keys '{keys}' (simulated)")]
+
+ elif action == "go_back":
+ return [
+ types.TextContent(
+ type="text",
+ text=f"Go back from: {_browser_state['current_url'] or 'None'} (simulated)",
+ )
+ ]
+
+ else:
+ return [
+ types.TextContent(type="text", text=f"Error: Unknown action '{action}'")
+ ]
+
+
+# ============================================================================
+# Resource Subscription Support
+# ============================================================================
+
+_resource_subscribers: Dict[str, set] = {}
+_resource_change_callbacks: Dict[str, list] = {}
+
+
+def subscribe_to_resource(resource_uri: str, client_id: str) -> str:
+ _resource_subscribers.setdefault(resource_uri, set()).add(client_id)
+ return f"Subscribed to resource: {resource_uri}"
+
+
+def unsubscribe_from_resource(resource_uri: str, client_id: str) -> str:
+ if resource_uri in _resource_subscribers:
+ _resource_subscribers[resource_uri].discard(client_id)
+ if not _resource_subscribers[resource_uri]:
+ del _resource_subscribers[resource_uri]
+ return f"Unsubscribed from resource: {resource_uri}"
+
+
+def list_subscriptions(resource_uri: Optional[str] = None) -> str:
+ if resource_uri:
+ subs = _resource_subscribers.get(resource_uri, set())
+ return json.dumps(
+ {"resource": resource_uri, "subscribers": list(subs), "count": len(subs)},
+ indent=2,
+ )
+ return json.dumps(
+ {
+ u: {"subscribers": list(s), "count": len(s)}
+ for u, s in _resource_subscribers.items()
+ },
+ indent=2,
+ )
+
+
+def notify_resource_change(resource_uri: str):
+ for cb in _resource_change_callbacks.get(resource_uri, []):
+ try:
+ cb(resource_uri)
+ except Exception as e:
+ logger.error(f"Resource change callback error: {e}")
+
+
+# ============================================================================
+# Health Check
+# ============================================================================
+
+_server_start_time: float = time.time()
+
+
+def _get_llm_caps() -> dict:
+ """Return capability info for the default LLM instance (safe — never throws)."""
+ try:
+ from app.llm import LLM, _is_local_server
+
+ llm = LLM()
+ return {
+ "model": llm.model,
+ "base_url": llm.base_url,
+ "is_local_server": _is_local_server(llm.base_url),
+ "caps_thinking": llm.caps_thinking,
+ "caps_vision": llm.caps_vision,
+ "thinking_enabled": llm.thinking_enabled,
+ "enable_thinking_config": llm._enable_thinking,
+ }
+ except Exception as exc:
+ return {"error": str(exc)}
+
+
+def get_health_status() -> dict:
+ uptime = time.time() - _server_start_time
+ tasks = agent.task_manager.tasks
+
+ def _count(s):
+ return sum(1 for t in tasks.values() if t.status == s)
+
+ days = int(uptime // 86400)
+ hours = int((uptime % 86400) // 3600)
+ minutes = int((uptime % 3600) // 60)
+ secs = int(uptime % 60)
+ parts = (
+ ([f"{days}d"] if days else [])
+ + ([f"{hours}h"] if hours else [])
+ + ([f"{minutes}m"] if minutes else [])
+ + [f"{secs}s"]
+ )
+ return {
+ "status": "healthy",
+ "server": {
+ "name": "openmanus-mcp-server",
+ "version": "1.0.0",
+ "uptime_seconds": round(uptime, 2),
+ "uptime_human": " ".join(parts),
+ },
+ "tasks": {
+ "total": len(tasks),
+ "running": _count(TaskStatus.RUNNING),
+ "pending": _count(TaskStatus.PENDING),
+ "completed": _count(TaskStatus.COMPLETED),
+ "failed": _count(TaskStatus.FAILED),
+ "cancelled": _count(TaskStatus.CANCELLED),
+ },
+ "subscriptions": {
+ "resources": len(_resource_subscribers),
+ "total_clients": sum(len(s) for s in _resource_subscribers.values()),
+ },
+ "browser": {
+ "page_loaded": bool(_browser_state.get("page_content")),
+ "current_url": _browser_state.get("current_url", ""),
+ },
+ "llm": _get_llm_caps(),
+ "timestamp": datetime.datetime.now().isoformat(),
+ }
+
+
+# ============================================================================
+# Unified Resource Dispatcher
+# ============================================================================
+
+
+@app.list_resources()
+async def list_resources() -> list[Resource]:
+ """List available resources."""
+ return RESOURCES
+
+
+@app.read_resource()
+async def read_resource(uri: str) -> str:
+ """Route resource reads to the appropriate handler."""
+ if uri == "openmanus://config":
+ return json.dumps(
+ {
+ "server": {"name": "openmanus-mcp-server", "version": "1.0.0"},
+ "agent": {"max_steps": 30, "model": "gpt-4o"},
+ "security": {"sandbox_enabled": True},
+ },
+ indent=2,
+ )
+ elif uri == "openmanus://templates/code_review":
+ return """# Code Review Request\n\nPlease review the following code:\n\n## File: {file_path}\n\n## Focus Areas: {focus_areas}\n\n## Review Criteria:\n1. Correctness\n2. Security\n3. Performance\n4. Readability\n5. Maintainability\n6. Testing\n"""
+ elif uri == "openmanus://templates/bug_fix":
+ return """# Bug Fix Request\n\n## Bug Description: {bug_description}\n\n## File: {file_path}\n\n## Request:\n1. Analyze root cause\n2. Propose a fix\n3. Suggest regression tests\n"""
+ elif uri == "openmanus://templates/feature_impl":
+ return """# Feature Implementation Request\n\n## Feature: {feature_description}\n\n## Tech Stack: {tech_stack}\n\n## Request:\n1. Design architecture\n2. Create implementation plan\n3. Generate code\n4. Suggest tests\n"""
+ else:
+ raise ValueError(f"Unknown resource URI: {uri}")
+
+
+# ============================================================================
+# Unified Prompt Dispatcher
+# ============================================================================
+
+
+@app.list_prompts()
+async def list_prompts() -> list[Prompt]:
+ """List available prompts."""
+ return PROMPTS
+
+
+@app.get_prompt()
+async def get_prompt(name: str, arguments: dict) -> types.GetPromptResult:
+ """Route prompt requests to the appropriate handler."""
+
+ def _msg(text: str) -> types.GetPromptResult:
+ return types.GetPromptResult(
+ description=name,
+ messages=[
+ types.PromptMessage(
+ role="user", content=types.TextContent(type="text", text=text)
+ )
+ ],
+ )
+
+ if name == "code_review":
+ return _msg(
+ f"Please review the code in {arguments.get('file_path','unknown')}, focusing on: {arguments.get('focus_areas','general')}"
+ )
+ elif name == "bug_fix":
+ return _msg(
+ f"Please help fix this bug: {arguments.get('bug_description','unknown')} in file {arguments.get('file_path','unknown')}"
+ )
+ elif name == "feature_impl":
+ return _msg(
+ f"Please implement this feature: {arguments.get('feature_description','unknown')} using {arguments.get('tech_stack','default')}"
+ )
+ elif name == "refactor":
+ return _msg(
+ f"Please refactor this code: {arguments.get('code_context','unknown')} with goals: {arguments.get('goals','general improvement')}"
+ )
+ elif name == "test_gen":
+ return _msg(
+ f"Please generate {arguments.get('test_framework','pytest')} tests for:\n\n{arguments.get('code','unknown')}"
+ )
+ else:
+ raise ValueError(f"Unknown prompt: {name}")
+
+
+# ============================================================================
+# Main Entry Point
+# ============================================================================
+
+
+async def main():
+ """Start the MCP server."""
+ import argparse
+ from io import TextIOWrapper
+
+ parser = argparse.ArgumentParser(description="OpenManus MCP Server")
+ parser.add_argument("--sse", action="store_true", help="Use SSE transport")
+ parser.add_argument("--port", type=int, default=8765, help="Port for SSE transport")
+ parser.add_argument("--config", type=str, help="Path to OpenManus config file")
+ args = parser.parse_args()
+
+ if args.config:
+ config_path = Path(args.config)
+ if config_path.exists():
+ with open(config_path) as f:
+ config = yaml.safe_load(f)
+ if "llm" in config:
+ agent.config["model"] = config["llm"].get("model", "gpt-4o")
+ if "agent" in config:
+ agent.config["max_steps"] = config["agent"].get("max_steps", 30)
+
+ if args.sse:
+ from fastapi import FastAPI
+ from mcp.server.sse import SseServerTransport
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
+
+ fastapi_app = FastAPI(title="OpenManus MCP Server")
+ fastapi_app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"])
+ sse = SseServerTransport("/messages/")
+
+ async with sse.connect_sse(fastapi_app, "/sse", "/messages/") as (
+ read_stream,
+ write_stream,
+ ):
+ await app.run(
+ read_stream,
+ write_stream,
+ InitializationOptions(
+ server_name="openmanus-mcp-server",
+ server_version="1.0.0",
+ capabilities=sse.get_capabilities(
+ notification_options=NotificationOptions(),
+ experimental_capabilities={},
+ ),
+ ),
+ )
+ else:
+ # ------------------------------------------------------------------ #
+ # Stdio transport — stdout MUST stay clean JSON-RPC. #
+ # sys.stdout was already redirected to stderr at module load. #
+ # Use _MCP_STDOUT_BUFFER (saved before redirect) for the MCP pipe. #
+ # ------------------------------------------------------------------ #
+ from io import TextIOWrapper
+
+ import anyio
+
+ mcp_stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8"))
+ mcp_stdout = anyio.wrap_file(
+ TextIOWrapper(_MCP_STDOUT_BUFFER, encoding="utf-8")
+ )
+
+ async with stdio_server(stdin=mcp_stdin, stdout=mcp_stdout) as (
+ read_stream,
+ write_stream,
+ ):
+ await app.run(
+ read_stream,
+ write_stream,
+ InitializationOptions(
+ server_name="openmanus-mcp-server",
+ server_version="1.0.0",
+ capabilities=types.ServerCapabilities(
+ prompts=types.PromptsCapability(listChanged=True),
+ resources=types.ResourcesCapability(
+ subscribe=True, listChanged=True
+ ),
+ tools=types.ToolsCapability(listChanged=True),
+ logging={},
+ ),
+ experimental_capabilities={},
+ ),
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/openmanus_mcp_server/tests/__init__.py b/openmanus_mcp_server/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/openmanus_mcp_server/tests/test_server.py b/openmanus_mcp_server/tests/test_server.py
new file mode 100644
index 000000000..fc3f0fb67
--- /dev/null
+++ b/openmanus_mcp_server/tests/test_server.py
@@ -0,0 +1,730 @@
+"""
+Tests for OpenManus MCP Server components.
+
+Run with: pytest tests/ -v
+"""
+
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+
+# Add the server module to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+
+@pytest.fixture
+def temp_dir():
+ """Create a temporary directory for testing."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield tmpdir
+
+
+@pytest.fixture
+def temp_file(temp_dir):
+ """Create a temporary file for testing."""
+ filepath = os.path.join(temp_dir, "test_file.txt")
+ with open(filepath, "w") as f:
+ f.write("test content")
+ yield filepath
+
+
+@pytest.fixture
+def task_manager():
+ """Create a TaskManager instance for testing."""
+ from openmanus_mcp_server.server import TaskManager
+
+ return TaskManager(output_base_dir=tempfile.mkdtemp())
+
+
+@pytest.fixture
+def openmanus_agent():
+ """Create an OpenManusAgent instance for testing."""
+ from openmanus_mcp_server.server import OpenManusAgent
+
+ return OpenManusAgent()
+
+
+# ============================================================================
+# TaskManager Tests
+# ============================================================================
+
+
+class TestTaskManager:
+ """Tests for the TaskManager class."""
+
+ def test_create_task(self, task_manager):
+ """Test creating a task."""
+ task = task_manager.create_task(
+ task_name="Test Task",
+ description="A test task",
+ max_steps=10,
+ model="gpt-4o",
+ )
+ assert task.task_id.startswith("task_")
+ assert task.task_name == "Test Task"
+ assert task.description == "A test task"
+ assert task.max_steps == 10
+ assert task.model == "gpt-4o"
+ assert task.status.value == "pending"
+ assert task.output_dir is not None
+
+ def test_get_task(self, task_manager):
+ """Test getting a task by ID."""
+ task = task_manager.create_task("Test", "Test description")
+ retrieved = task_manager.get_task(task.task_id)
+ assert retrieved is not None
+ assert retrieved.task_id == task.task_id
+
+ def test_get_nonexistent_task(self, task_manager):
+ """Test getting a nonexistent task."""
+ retrieved = task_manager.get_task("task_nonexistent")
+ assert retrieved is None
+
+ def test_list_tasks(self, task_manager):
+ """Test listing all tasks."""
+ task_manager.create_task("Task 1", "Description 1")
+ task_manager.create_task("Task 2", "Description 2")
+ tasks = task_manager.list_tasks()
+ assert len(tasks) == 2
+
+ def test_update_status(self, task_manager):
+ """Test updating task status."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ task = task_manager.create_task("Test", "Test")
+ task_manager.update_status(task.task_id, TaskStatus.RUNNING)
+ assert task.status == TaskStatus.RUNNING
+
+ def test_update_status_completed(self, task_manager):
+ """Test updating task status to completed."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ task = task_manager.create_task("Test", "Test")
+ task_manager.update_status(task.task_id, TaskStatus.COMPLETED)
+ assert task.status == TaskStatus.COMPLETED
+ assert task.completed_at is not None
+
+ def test_update_progress(self, task_manager):
+ """Test updating task progress."""
+ task = task_manager.create_task("Test", "Test")
+ task_manager.update_progress(task.task_id, 5, 10)
+ assert task.progress == 5
+ assert task.total_steps == 10
+
+ def test_set_result(self, task_manager):
+ """Test setting task result."""
+ task = task_manager.create_task("Test", "Test")
+ task_manager.set_result(task.task_id, "Success!")
+ assert task.result == "Success!"
+
+ def test_set_error(self, task_manager):
+ """Test setting task error."""
+ task = task_manager.create_task("Test", "Test")
+ task_manager.set_error(task.task_id, "Something went wrong")
+ assert task.error == "Something went wrong"
+
+ def test_cancel_task(self, task_manager):
+ """Test cancelling a task."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ task = task_manager.create_task("Test", "Test")
+ result = task_manager.cancel_task(task.task_id)
+ assert result is True
+ assert task.status == TaskStatus.CANCELLED
+ assert task.completed_at is not None
+
+ def test_cancel_nonexistent_task(self, task_manager):
+ """Test cancelling a nonexistent task."""
+ result = task_manager.cancel_task("task_nonexistent")
+ assert result is False
+
+ def test_delete_task(self, task_manager):
+ """Test deleting a task."""
+ task = task_manager.create_task("Test", "Test")
+ result = task_manager.delete_task(task.task_id)
+ assert result is True
+ assert task_manager.get_task(task.task_id) is None
+
+ def test_delete_nonexistent_task(self, task_manager):
+ """Test deleting a nonexistent task."""
+ result = task_manager.delete_task("task_nonexistent")
+ assert result is False
+
+ def test_task_to_dict(self, task_manager):
+ """Test converting task to dictionary."""
+ task = task_manager.create_task("Test", "Test")
+ d = task.to_dict()
+ assert d["task_id"] == task.task_id
+ assert d["task_name"] == "Test"
+ assert d["status"] == "pending"
+ assert "created_at" in d
+ assert "completed_at" in d
+
+
+# ============================================================================
+# Security Utility Tests
+# ============================================================================
+
+
+class TestSecurityUtilities:
+ """Tests for security utility functions."""
+
+ def test_resolve_path_safely_valid_path(self, task_manager):
+ """Test resolving a valid path."""
+ from openmanus_mcp_server.server import resolve_path_safely
+
+ result = resolve_path_safely("/tmp/test.txt")
+ assert result is not None
+ assert str(result).endswith("test.txt")
+
+ def test_resolve_path_safely_traversal(self, task_manager):
+ """Test that path traversal is blocked."""
+ # Set allowed dirs to /tmp only
+ from openmanus_mcp_server.server import ALLOWED_WORKSPACES, resolve_path_safely
+
+ original = ALLOWED_WORKSPACES.copy()
+ try:
+ ALLOWED_WORKSPACES.clear()
+ ALLOWED_WORKSPACES.append("/tmp")
+ # Try to access /etc/passwd
+ result = resolve_path_safely("/etc/passwd")
+ assert result is None
+ finally:
+ ALLOWED_WORKSPACES.clear()
+ ALLOWED_WORKSPACES.extend(original)
+
+ def test_resolve_path_safely_relative(self, task_manager):
+ """Test resolving a relative path."""
+ from openmanus_mcp_server.server import resolve_path_safely
+
+ result = resolve_path_safely("./server.py")
+ assert result is not None
+
+ def test_sanitize_git_args_safe(self):
+ """Test sanitizing safe git arguments."""
+ from openmanus_mcp_server.server import sanitize_git_args
+
+ result = sanitize_git_args("--oneline -n 5")
+ assert result == ["--oneline", "-n", "5"]
+
+ def test_sanitize_git_args_dangerous(self):
+ """Test blocking dangerous git arguments."""
+ from openmanus_mcp_server.server import sanitize_git_args
+
+ result = sanitize_git_args("log; rm -rf /")
+ assert ";" not in result
+ assert "rm" not in result
+
+ def test_sanitize_git_args_empty(self):
+ """Test sanitizing empty arguments."""
+ from openmanus_mcp_server.server import sanitize_git_args
+
+ result = sanitize_git_args("")
+ assert result == []
+
+
+# ============================================================================
+# OpenManusAgent Tests
+# ============================================================================
+
+
+class TestOpenManusAgent:
+ """Tests for the OpenManusAgent class."""
+
+ def test_execute_task(self, openmanus_agent):
+ """Test executing a task."""
+ result = openmanus_agent.execute_task("Test task", max_steps=5)
+ assert "completed" in result.lower()
+
+ def test_get_task_status(self, openmanus_agent):
+ """Test getting task status."""
+ result = openmanus_agent.get_task_status("task_nonexistent")
+ assert "not found" in result.lower()
+
+ def test_cancel_task(self, openmanus_agent):
+ """Test cancelling a task."""
+ result = openmanus_agent.cancel_task("task_nonexistent")
+ assert "not found" in result.lower() or "cancelled" in result.lower()
+
+ def test_list_tasks(self, openmanus_agent):
+ """Test listing tasks."""
+ openmanus_agent.execute_task("Test task 1")
+ openmanus_agent.execute_task("Test task 2")
+ result = openmanus_agent.list_tasks()
+ tasks = json.loads(result)
+ assert len(tasks) == 2
+
+ def test_read_output_nonexistent(self, openmanus_agent):
+ """Test reading output from nonexistent task."""
+ result = openmanus_agent.read_output("task_nonexistent", "file.txt")
+ assert "not found" in result.lower()
+
+
+# ============================================================================
+# Browser Helper Function Tests
+# ============================================================================
+
+
+class TestBrowserHelpers:
+ """Tests for browser helper functions."""
+
+ def test_extract_text_from_html(self):
+ """Test extracting text from HTML."""
+ from openmanus_mcp_server.server import _extract_text_from_html
+
+ html = "
Hello World
"
+ text = _extract_text_from_html(html)
+ assert "Hello" in text
+ assert "World" in text
+
+ def test_extract_text_from_html_with_script(self):
+ """Test that script content is excluded."""
+ from openmanus_mcp_server.server import _extract_text_from_html
+
+ html = (
+ "
Safe content
"
+ )
+ text = _extract_text_from_html(html)
+ assert "alert" not in text
+ assert "Safe content" in text
+
+ def test_get_page_title(self):
+ """Test extracting page title."""
+ from openmanus_mcp_server.server import _get_page_title
+
+ html = "Test Page"
+ title = _get_page_title(html)
+ assert title == "Test Page"
+
+ def test_get_page_title_no_title(self):
+ """Test extracting page title when none exists."""
+ from openmanus_mcp_server.server import _get_page_title
+
+ html = "No title"
+ title = _get_page_title(html)
+ assert title == "No title"
+
+ def test_extract_clickable_elements(self):
+ """Test extracting clickable elements."""
+ from openmanus_mcp_server.server import _extract_clickable_elements
+
+ html = 'Link'
+ elements = _extract_clickable_elements(html)
+ assert len(elements) >= 2
+
+ def test_extract_clickable_elements_empty(self):
+ """Test extracting clickable elements from empty HTML."""
+ from openmanus_mcp_server.server import _extract_clickable_elements
+
+ elements = _extract_clickable_elements("")
+ assert elements == []
+
+
+# ============================================================================
+# TaskOutput Tests
+# ============================================================================
+
+
+class TestTaskOutput:
+ """Tests for the TaskOutput dataclass."""
+
+ def test_task_output_creation(self):
+ """Test creating a TaskOutput."""
+ from openmanus_mcp_server.server import TaskOutput
+
+ output = TaskOutput(type="text", text="Hello")
+ assert output.type == "text"
+ assert output.text == "Hello"
+ assert output.data is None
+ assert output.uri is None
+
+ def test_task_output_with_all_fields(self):
+ """Test creating a TaskOutput with all fields."""
+ from openmanus_mcp_server.server import TaskOutput
+
+ output = TaskOutput(
+ type="image",
+ text="img",
+ data="base64data",
+ uri="http://example.com/img.png",
+ )
+ assert output.type == "image"
+ assert output.text == "img"
+ assert output.data == "base64data"
+ assert output.uri == "http://example.com/img.png"
+
+
+# ============================================================================
+# TaskStatus Enum Tests
+# ============================================================================
+
+
+class TestTaskStatus:
+ """Tests for the TaskStatus enum."""
+
+ def test_all_statuses_exist(self):
+ """Test all status values exist."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ assert hasattr(TaskStatus, "PENDING")
+ assert hasattr(TaskStatus, "RUNNING")
+ assert hasattr(TaskStatus, "COMPLETED")
+ assert hasattr(TaskStatus, "FAILED")
+ assert hasattr(TaskStatus, "CANCELLED")
+
+ def test_status_values(self):
+ """Test status enum values."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ assert TaskStatus.PENDING.value == "pending"
+ assert TaskStatus.RUNNING.value == "running"
+ assert TaskStatus.COMPLETED.value == "completed"
+ assert TaskStatus.FAILED.value == "failed"
+ assert TaskStatus.CANCELLED.value == "cancelled"
+
+
+# ============================================================================
+# Health Check Tests
+# ============================================================================
+
+
+class TestHealthCheck:
+ """Tests for the health check functionality."""
+
+ def test_get_health_status(self, openmanus_agent):
+ """Test getting health status."""
+ from openmanus_mcp_server.server import get_health_status
+
+ status = get_health_status()
+ assert status["status"] == "healthy"
+ assert "server" in status
+ assert "tasks" in status
+ assert "subscriptions" in status
+ assert "browser" in status
+ assert "timestamp" in status
+
+ def test_health_status_server_info(self, openmanus_agent):
+ """Test health status server info."""
+ from openmanus_mcp_server.server import get_health_status
+
+ status = get_health_status()
+ assert status["server"]["name"] == "openmanus-mcp-server"
+ assert status["server"]["version"] == "1.0.0"
+ assert "uptime_seconds" in status["server"]
+ assert "uptime_human" in status["server"]
+
+ def test_health_status_task_counts(self, openmanus_agent):
+ """Test health status task counts."""
+ from openmanus_mcp_server.server import get_health_status
+
+ # Create some tasks
+ openmanus_agent.execute_task("Test task 1")
+ openmanus_agent.execute_task("Test task 2")
+ status = get_health_status()
+ assert status["tasks"]["total"] >= 2
+ assert status["tasks"]["completed"] >= 2
+
+ def test_health_status_browser_state(self, openmanus_agent):
+ """Test health status browser state."""
+ from openmanus_mcp_server.server import get_health_status
+
+ status = get_health_status()
+ assert "page_loaded" in status["browser"]
+ assert "current_url" in status["browser"]
+
+ def test_format_uptime(self):
+ """Test uptime formatting."""
+ from openmanus_mcp_server.server import _format_uptime
+
+ # Test seconds
+ assert "1s" in _format_uptime(1)
+ # Test minutes
+ assert "1m" in _format_uptime(61)
+ # Test hours
+ assert "1h" in _format_uptime(3661)
+ # Test days
+ assert "1d" in _format_uptime(86401)
+
+
+# ============================================================================
+# Resource Subscription Tests
+# ============================================================================
+
+
+class TestResourceSubscription:
+ """Tests for resource subscription functionality."""
+
+ def test_subscribe_to_resource(self):
+ """Test subscribing to a resource."""
+ from openmanus_mcp_server.server import subscribe_to_resource
+
+ result = subscribe_to_resource("openmanus://config", "client_1")
+ assert "Subscribed" in result
+
+ def test_unsubscribe_from_resource(self):
+ """Test unsubscribing from a resource."""
+ from openmanus_mcp_server.server import (
+ subscribe_to_resource,
+ unsubscribe_from_resource,
+ )
+
+ subscribe_to_resource("openmanus://config", "client_1")
+ result = unsubscribe_from_resource("openmanus://config", "client_1")
+ assert "Unsubscribed" in result
+
+ def test_list_subscriptions_all(self):
+ """Test listing all subscriptions."""
+ import json
+
+ from openmanus_mcp_server.server import (
+ list_subscriptions,
+ subscribe_to_resource,
+ )
+
+ subscribe_to_resource("openmanus://config", "client_1")
+ subscribe_to_resource("openmanus://config", "client_2")
+ result = list_subscriptions()
+ data = json.loads(result)
+ assert "openmanus://config" in data
+ assert data["openmanus://config"]["count"] == 2
+
+ def test_list_subscriptions_filter(self):
+ """Test listing subscriptions filtered by URI."""
+ import json
+
+ from openmanus_mcp_server.server import (
+ list_subscriptions,
+ subscribe_to_resource,
+ )
+
+ subscribe_to_resource("openmanus://config", "client_1")
+ result = list_subscriptions("openmanus://config")
+ data = json.loads(result)
+ assert data["resource"] == "openmanus://config"
+ assert data["count"] == 1
+
+ def test_unsubscribe_removes_empty(self):
+ """Test that unsubscribing removes empty subscription sets."""
+ from openmanus_mcp_server.server import (
+ _resource_subscribers,
+ subscribe_to_resource,
+ unsubscribe_from_resource,
+ )
+
+ len(_resource_subscribers)
+ subscribe_to_resource("openmanus://test", "client_1")
+ unsubscribe_from_resource("openmanus://test", "client_1")
+ # The subscription should be cleaned up
+ assert "openmanus://test" not in _resource_subscribers
+
+
+# ============================================================================
+# Task Output Tests
+# ============================================================================
+
+
+class TestTaskOutput:
+ """Tests for task output file generation."""
+
+ def test_execute_task_creates_report(self, openmanus_agent, temp_dir):
+ """Test that execute_task creates a report file."""
+ task = openmanus_agent.task_manager.create_task(
+ "Test Report", "Test task with code review", output_dir=temp_dir
+ )
+ result = openmanus_agent._simulate_task_execution(task)
+ assert "completed" in result.lower()
+ report_path = Path(temp_dir) / "report.md"
+ assert report_path.exists()
+ content = report_path.read_text()
+ assert "Test Report" in content
+
+ def test_execute_task_creates_summary(self, openmanus_agent, temp_dir):
+ """Test that execute_task creates a summary.json file."""
+ task = openmanus_agent.task_manager.create_task(
+ "Test Summary", "Test task with file operations", output_dir=temp_dir
+ )
+ openmanus_agent._simulate_task_execution(task)
+ summary_path = Path(temp_dir) / "summary.json"
+ assert summary_path.exists()
+ import json
+
+ summary = json.loads(summary_path.read_text())
+ assert summary["status"] == "completed"
+ assert "analysis" in summary
+
+ def test_execute_task_keyword_code(self, openmanus_agent, temp_dir):
+ """Test keyword detection for code-related tasks."""
+ task = openmanus_agent.task_manager.create_task(
+ "Code Review", "Review the code for bugs", output_dir=temp_dir
+ )
+ openmanus_agent._simulate_task_execution(task)
+ report_path = Path(temp_dir) / "report.md"
+ content = report_path.read_text()
+ assert "Code Analysis" in content
+
+ def test_execute_task_keyword_search(self, openmanus_agent, temp_dir):
+ """Test keyword detection for search-related tasks."""
+ task = openmanus_agent.task_manager.create_task(
+ "Web Search", "Search the web for information", output_dir=temp_dir
+ )
+ openmanus_agent._simulate_task_execution(task)
+ report_path = Path(temp_dir) / "report.md"
+ content = report_path.read_text()
+ assert "Web Research" in content
+
+ def test_execute_task_keyword_git(self, openmanus_agent, temp_dir):
+ """Test keyword detection for git-related tasks."""
+ task = openmanus_agent.task_manager.create_task(
+ "Git Task", "Commit changes and push to remote", output_dir=temp_dir
+ )
+ openmanus_agent._simulate_task_execution(task)
+ report_path = Path(temp_dir) / "report.md"
+ content = report_path.read_text()
+ assert "Git Operations" in content
+
+ def test_execute_task_general(self, openmanus_agent, temp_dir):
+ """Test general task detection when no keywords match."""
+ task = openmanus_agent.task_manager.create_task(
+ "General Task", "Do something unrelated", output_dir=temp_dir
+ )
+ openmanus_agent._simulate_task_execution(task)
+ report_path = Path(temp_dir) / "report.md"
+ content = report_path.read_text()
+ assert "General Task" in content
+
+
+# ============================================================================
+# Integration Tests
+# ============================================================================
+
+
+class TestIntegration:
+ """Integration tests for the server components."""
+
+ def test_full_task_lifecycle(self, openmanus_agent):
+ """Test the full task lifecycle."""
+ # Create and execute task
+ result = openmanus_agent.execute_task("Integration test task", max_steps=10)
+
+ # Parse task ID from result
+ assert "completed" in result.lower()
+
+ # Get status
+ status = openmanus_agent.get_task_status("task_nonexistent")
+ assert "not found" in status.lower()
+
+ # List tasks
+ tasks = json.loads(openmanus_agent.list_tasks())
+ assert len(tasks) >= 1
+
+ def test_task_manager_with_custom_output_dir(self, temp_dir):
+ """Test TaskManager with custom output directory."""
+ from openmanus_mcp_server.server import TaskManager
+
+ tm = TaskManager(output_base_dir=temp_dir)
+ task = tm.create_task("Test", "Test")
+ assert task.output_dir.startswith(temp_dir)
+
+ def test_multiple_tasks_different_models(self, openmanus_agent):
+ """Test creating tasks with different models."""
+ task1 = openmanus_agent.execute_task("Task 1", model="gpt-4o")
+ task2 = openmanus_agent.execute_task("Task 2", model="claude-3-opus")
+
+ tasks = json.loads(openmanus_agent.list_tasks())
+ models = [t["model"] for t in tasks]
+ assert "gpt-4o" in models
+ assert "claude-3-opus" in models
+
+ def test_health_check_integration(self, openmanus_agent):
+ """Test health check as part of integration."""
+ from openmanus_mcp_server.server import get_health_status
+
+ openmanus_agent.execute_task("Integration health test")
+ status = get_health_status()
+ assert status["status"] == "healthy"
+ assert status["tasks"]["total"] >= 1
+
+
+# ============================================================================
+# Edge Case Tests
+# ============================================================================
+
+
+class TestEdgeCases:
+ """Tests for edge cases and error handling."""
+
+ def test_task_manager_output_dir_creation(self, temp_dir):
+ """Test that output directory is created if it doesn't exist."""
+ from openmanus_mcp_server.server import TaskManager
+
+ new_dir = os.path.join(temp_dir, "new_output_dir")
+ tm = TaskManager(output_base_dir=new_dir)
+ assert os.path.exists(new_dir)
+
+ def test_task_id_uniqueness(self, task_manager):
+ """Test that task IDs are unique."""
+ task1 = task_manager.create_task("Task 1", "Test")
+ task2 = task_manager.create_task("Task 2", "Test")
+ assert task1.task_id != task2.task_id
+
+ def test_task_progress_boundary(self, task_manager):
+ """Test task progress at boundaries."""
+ task = task_manager.create_task("Test", "Test")
+ task_manager.update_progress(task.task_id, 0, 10)
+ assert task.progress == 0
+ task_manager.update_progress(task.task_id, 10, 10)
+ assert task.progress == 10
+
+ def test_cancel_completed_task(self, task_manager):
+ """Test cancelling an already completed task."""
+ from openmanus_mcp_server.server import TaskStatus
+
+ task = task_manager.create_task("Test", "Test")
+ task_manager.update_status(task.task_id, TaskStatus.COMPLETED)
+ result = task_manager.cancel_task(task.task_id)
+ assert result is True # Should still return True even if already completed
+
+ def test_empty_task_description(self, openmanus_agent):
+ """Test handling empty task description."""
+ task = openmanus_agent.task_manager.create_task(
+ "", "Empty task name", output_dir=tempfile.mkdtemp()
+ )
+ result = openmanus_agent._simulate_task_execution(task)
+ assert "completed" in result.lower()
+
+ def test_very_long_task_description(self, openmanus_agent, temp_dir):
+ """Test handling very long task descriptions."""
+ long_desc = "x" * 10000
+ task = openmanus_agent.task_manager.create_task(
+ long_desc[:50], long_desc, output_dir=temp_dir
+ )
+ result = openmanus_agent._simulate_task_execution(task)
+ assert "completed" in result.lower()
+
+ def test_special_characters_in_path(self, task_manager):
+ """Test handling special characters in paths."""
+ from openmanus_mcp_server.server import resolve_path_safely
+
+ result = resolve_path_safely("/tmp/test file (1).txt")
+ assert result is not None
+
+ def test_delete_and_recreate_task(self, task_manager):
+ """Test deleting and recreating a task."""
+ task = task_manager.create_task("Test", "Test")
+ task_manager.delete_task(task.task_id)
+ new_task = task_manager.create_task("New Test", "New Test")
+ assert new_task.task_id != task.task_id
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/requirements.txt b/requirements.txt
index aa7e6dc93..58aa3828d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,42 +1,171 @@
-pydantic~=2.10.6
-openai~=1.66.3
-tenacity~=9.0.0
-pyyaml~=6.0.2
-loguru~=0.7.3
-numpy
-datasets~=3.4.1
-fastapi~=0.115.11
-tiktoken~=0.9.0
-
-html2text~=2024.2.26
-gymnasium~=1.1.1
-pillow~=11.1.0
-browsergym~=0.13.3
-uvicorn~=0.34.0
-unidiff~=0.7.5
-browser-use~=0.1.40
-googlesearch-python~=1.3.0
-baidusearch~=1.0.3
-duckduckgo_search~=7.5.3
-
-aiofiles~=24.1.0
-pydantic_core~=2.27.2
-colorama~=0.4.6
-playwright~=1.51.0
-
-docker~=7.1.0
-pytest~=8.3.5
-pytest-asyncio~=0.25.3
-
-mcp~=1.5.0
-httpx>=0.27.0
-tomli>=2.0.0
-
-boto3~=1.37.18
-
-requests~=2.32.3
-beautifulsoup4~=4.13.3
-crawl4ai~=0.6.3
-
-huggingface-hub~=0.29.2
-setuptools~=75.8.0
+aiofiles==24.1.0
+aiohappyeyeballs==2.6.2
+aiohttp==3.13.5
+aiohttp-retry==2.9.1
+aiosignal==1.4.0
+aiosqlite==0.22.1
+amqp==5.3.1
+annotated-doc==0.0.4
+annotated-types==0.7.0
+anthropic==0.104.0
+anyio==4.13.0
+attrs==26.1.0
+backoff==2.2.1
+baidusearch==1.0.3
+beautifulsoup4==4.13.5
+billiard==4.2.4
+boto3==1.37.38
+botocore==1.37.38
+brotli==1.2.0
+browser-use==0.1.40
+celery==5.4.0
+certifi==2026.5.20
+cffi==2.0.0
+chardet==7.4.3
+charset-normalizer==3.4.7
+click==8.4.0
+click-didyoumean==0.3.1
+click-plugins==1.1.1.2
+click-repl==0.3.0
+cloakbrowser==0.3.30
+colorama==0.4.6
+Crawl4AI==0.6.3
+cryptography==48.0.0
+cssselect==1.4.0
+daytona==0.169.0
+daytona_api_client==0.169.0
+daytona_api_client_async==0.169.0
+daytona_toolbox_api_client==0.169.0
+daytona_toolbox_api_client_async==0.169.0
+defusedxml==0.7.1
+Deprecated==1.3.1
+distro==1.9.0
+docker==7.1.0
+docstring_parser==0.18.0
+duckduckgo_search==7.5.5
+fake-http-header==0.3.5
+fake-useragent==2.2.0
+faiss-cpu==1.14.3
+fastapi==0.115.14
+filelock==3.29.0
+frozenlist==1.8.0
+fsspec==2026.4.0
+googleapis-common-protos==1.75.0
+googlesearch-python==1.3.0
+greenlet==3.5.1
+h11==0.16.0
+hf-xet==1.5.0
+html2text==2024.2.26
+httpcore==1.0.9
+httpx==0.28.1
+httpx-sse==0.4.3
+huggingface_hub==1.16.1
+humanize==4.15.0
+idna==3.15
+importlib_metadata==9.0.0
+iniconfig==2.3.0
+Jinja2==3.1.6
+jiter==0.15.0
+jmespath==1.1.0
+joblib==1.5.3
+jsonpatch==1.33
+jsonpointer==3.1.1
+jsonschema==4.26.0
+jsonschema-specifications==2025.9.1
+kombu==5.6.2
+langchain-anthropic==0.3.3
+langchain-core==0.3.86
+langchain-ollama==0.2.2
+langchain-openai==0.3.1
+langsmith==0.8.5
+litellm==1.65.0.post1
+loguru==0.7.3
+lxml==5.4.0
+markdown-it-py==4.2.0
+markdownify==0.14.1
+MarkupSafe==3.0.3
+mcp==1.5.0
+mdurl==0.1.2
+multidict==6.7.1
+nltk==3.9.4
+numpy==2.4.4
+obstore==0.8.2
+ollama==0.6.2
+openai==1.66.5
+opentelemetry-api==1.42.1
+opentelemetry-exporter-otlp-proto-common==1.42.1
+opentelemetry-exporter-otlp-proto-http==1.42.1
+opentelemetry-instrumentation==0.63b1
+opentelemetry-instrumentation-aiohttp-client==0.63b1
+opentelemetry-proto==1.42.1
+opentelemetry-sdk==1.42.1
+opentelemetry-semantic-conventions==0.63b1
+opentelemetry-util-http==0.63b1
+orjson==3.11.9
+packaging==25.0
+pillow==10.4.0
+playwright==1.51.0
+pluggy==1.6.0
+posthog==7.15.3
+primp==1.3.0
+prompt_toolkit==3.0.52
+propcache==0.5.2
+protobuf==6.33.6
+psutil==7.2.2
+psycopg2-binary==2.9.12
+pycparser==3.0
+pydantic==2.10.6
+pydantic-settings==2.14.1
+pydantic_core==2.27.2
+pyee==12.1.1
+Pygments==2.20.0
+pyOpenSSL==26.2.0
+pyperclip==1.11.0
+pytest==8.3.5
+pytest-asyncio==0.25.3
+python-dateutil==2.9.0.post0
+python-dotenv==1.2.2
+python-multipart==0.0.29
+PyYAML==6.0.3
+rank-bm25==0.2.2
+redis==5.2.1
+referencing==0.37.0
+regex==2026.5.9
+requests==2.32.5
+requests-toolbelt==1.0.0
+rich==15.0.0
+rpds-py==0.30.0
+s3transfer==0.11.5
+setuptools==75.8.2
+shellingham==1.5.4
+six==1.17.0
+sniffio==1.3.1
+snowballstemmer==2.2.0
+soupsieve==2.8.3
+SQLAlchemy==2.0.49
+sse-starlette==3.0.3
+starlette==0.46.2
+structlog==25.5.0
+tenacity==9.0.0
+tf-playwright-stealth==1.2.0
+tiktoken==0.9.0
+tokenizers==0.23.1
+toml==0.10.2
+tomli==2.4.1
+tqdm==4.67.3
+typer==0.25.1
+typing-inspection==0.4.2
+typing_extensions==4.15.0
+tzdata==2026.2
+unidiff==0.7.5
+urllib3==2.7.0
+uuid_utils==0.16.0
+uvicorn==0.34.3
+vine==5.1.0
+wcwidth==0.7.0
+websockets==15.0.1
+wrapt==2.2.0
+xxhash==3.7.0
+yarl==1.24.2
+zipp==4.1.0
+zstandard==0.25.0
diff --git a/research/deepspec/README.md b/research/deepspec/README.md
new file mode 100644
index 000000000..6499b900b
--- /dev/null
+++ b/research/deepspec/README.md
@@ -0,0 +1,37 @@
+# DeepSpec Research Integration
+
+## Overview
+DeepSpec is an advanced speculative decoding research and evaluation framework developed by DeepSeek for exploring efficient inference acceleration techniques with large language models.
+
+This integration provides an opt-in research path within OpenManus for evaluating speculative decoding draft models against target models.
+
+## Crucial Architecture Notice
+- **Not Required for Runtime**: DeepSpec is strictly an optional research and evaluation tool. It is **not enabled** by default and is not built into the standard OpenManus production Docker container (`make build`).
+- **Resource Intensive**: Speculative decoding research workflows and target cache generation require substantial GPU compute and storage resources. For instance, default target cache generation for `Qwen/Qwen3-4B` can exceed roughly **38 TB** of disk storage. Use this integration exclusively for dedicated high-performance research environments.
+
+## Configuration
+To configure DeepSpec research settings in OpenManus, add or edit the `[deepspec]` section in your `config.toml`:
+
+```toml
+[deepspec]
+enabled = false
+repo_url = "https://github.com/deepseek-ai/DeepSpec"
+checkout_dir = "research/deepspec"
+mode = "research"
+target_model = "Qwen/Qwen3-4B"
+```
+
+## Preparation & Setup
+To prepare the upstream research repository without triggering any automatic training or cache generation, run the helper script:
+
+```bash
+./scripts/prepare_deepspec_research.sh
+```
+
+This script clones or updates the upstream DeepSpec repository into `research/deepspec/upstream/`.
+
+## Workflow
+Once the upstream repository is prepared:
+1. Review the upstream data preparation instructions inside `research/deepspec/upstream/README.md`.
+2. Prepare target model logits and hidden state caches on a dedicated high-capacity storage volume.
+3. Run evaluation scripts against your draft models using the prepared target cache.
diff --git a/research/openmanus-rl/README.md b/research/openmanus-rl/README.md
new file mode 100644
index 000000000..61bd65cdc
--- /dev/null
+++ b/research/openmanus-rl/README.md
@@ -0,0 +1,47 @@
+# OpenManus-RL Integration Workspace
+
+This directory is the local integration workspace for reinforcement-learning
+experiments and policy export.
+
+## Goal
+
+Keep the production runtime stable while allowing fast RL iteration in one
+repository.
+
+## Recommended layout
+
+`research/openmanus-rl/`
+
+- `artifacts/policy/latest/policy.md`: active policy guidance consumed by runtime
+- `artifacts/policy/latest/metadata.json`: optional metadata for traceability
+- `upstream/`: optional checkout of `OpenManus/OpenManus-RL`
+
+## Pulling upstream code
+
+You can choose one of the following:
+
+1. Submodule:
+ `git submodule add https://github.com/OpenManus/OpenManus-RL.git research/openmanus-rl/upstream`
+2. Subtree:
+ `git subtree add --prefix=research/openmanus-rl/upstream https://github.com/OpenManus/OpenManus-RL.git main --squash`
+3. Plain clone (no git linkage):
+ `git clone --recursive https://github.com/OpenManus/OpenManus-RL.git research/openmanus-rl/upstream`
+
+## Exporting policies into runtime
+
+Use:
+
+`python scripts/export_policy.py --policy-file --model --benchmark --run-id `
+
+This updates:
+
+- `research/openmanus-rl/artifacts/policy/latest/policy.md`
+- `research/openmanus-rl/artifacts/policy/latest/metadata.json`
+
+Runtime can then consume it by enabling:
+
+```toml
+[rl]
+enabled = true
+policy_mode = "rl"
+```
diff --git a/research/openmanus-rl/artifacts/policy/latest/metadata.json b/research/openmanus-rl/artifacts/policy/latest/metadata.json
new file mode 100644
index 000000000..c7f0927d0
--- /dev/null
+++ b/research/openmanus-rl/artifacts/policy/latest/metadata.json
@@ -0,0 +1,6 @@
+{
+ "run_id": "v1-grounded-heuristic",
+ "model": "all",
+ "benchmark": "SWE-Bench + WebArena + observed-failures",
+ "notes": "Hand-distilled policy from SWE-Bench agent failure analysis, WebArena browser agent traces, and observed failure patterns in this OpenManus deployment. Covers search fallback, file editing safety, bash discipline, planning, self-correction, memory, and termination."
+}
diff --git a/research/openmanus-rl/artifacts/policy/latest/policy.md b/research/openmanus-rl/artifacts/policy/latest/policy.md
new file mode 100644
index 000000000..26b8a984b
--- /dev/null
+++ b/research/openmanus-rl/artifacts/policy/latest/policy.md
@@ -0,0 +1,131 @@
+# OpenManus Execution Policy
+# Source: Distilled from SWE-Bench, WebArena, and AgentBench failure analysis
+# + observed failure patterns from this OpenManus deployment
+# Effective: 2026-05-22
+
+---
+
+## 1. SEARCH & INTERNET BROWSING
+
+**Rule S-1 (Search-First, Python-Never):**
+When you need to look something up on the internet, ALWAYS use the `web_search` tool first.
+Never use `python_execute` with `requests`, `httpx`, or `urllib` to scrape or search — these share
+the same network restrictions and will fail with the same errors. If `web_search` returns an error
+saying all engines failed, do NOT fall back to `python_execute`. Instead:
+- Rephrase the query into a shorter, more specific form.
+- Try a second `web_search` call with the rephrased query.
+- Use `browser_use` with `go_to_url` to visit a specific known URL directly.
+
+**Rule S-2 (Query Decomposition):**
+If a search returns off-topic results (e.g. wrong language, wrong domain), the query was too
+ambiguous. Break it into a more specific English-language query.
+Example: instead of "AI agents" try "site:arxiv.org AI agent planning benchmark 2024".
+
+**Rule S-3 (Browser as Last Resort):**
+Only escalate to `browser_use` after `web_search` succeeds and you need the full page body.
+Do not open new browser tabs unnecessarily — navigate within the current tab sequentially.
+
+---
+
+## 2. FILE EDITING
+
+**Rule E-1 (Read Before Write):**
+NEVER call `line_edit`, `apply_patch_editor`, or `str_replace_editor` on a file you have not
+read in the current step sequence. Always confirm exact line numbers first. Editing without
+reading causes 80%+ of code errors in benchmarks.
+
+**Rule E-2 (Single-Tool Discipline):**
+Do not mix `line_edit` and `str_replace_editor` on the same file in the same task.
+Preferred hierarchy: `line_edit` > `apply_patch_editor` > `str_replace_editor`.
+
+**Rule E-3 (Verify After Every Edit):**
+After every file edit, read back the modified lines to confirm the change landed correctly.
+For Python/TypeScript: run a syntax check (`python -m py_compile ` or `npx tsc --noEmit`)
+before claiming the task is done.
+
+---
+
+## 3. CODE EXECUTION & BASH
+
+**Rule B-1 (Short Chains):**
+Do not chain more than 3 commands with `&&` in a single bash call. Long chains make it impossible
+to identify which command failed. Run commands individually or in pairs, check the result, then
+continue.
+
+**Rule B-2 (Double Failure = Rethink):**
+If the same bash command fails twice in a row, STOP. Do not try a third time immediately.
+Call `planning` to write out your understanding of the failure and a revised plan.
+
+**Rule B-3 (Exit Code Discipline):**
+Always check that commands exit 0. Read the FULL stderr output before deciding on a fix.
+Never silently ignore stderr.
+
+---
+
+## 4. PLANNING & TASK DECOMPOSITION
+
+**Rule P-1 (Plan Before Complex Work):**
+For any task with more than ~5 logical steps (refactoring, multi-file changes, Docker builds,
+multi-stage research), call `planning` at the start to write a numbered execution plan.
+Update the plan after each phase completes or fails. This creates a recovery checkpoint.
+
+**Rule P-2 (Parallelise Independent Reads):**
+When you need information from multiple files or searches, issue them as parallel tool calls in
+one response. Do not chain: read A, then read B, then read C — issue all three at once.
+This is the single highest-impact latency optimization available.
+
+**Rule P-3 (Scope Guard):**
+Before starting any task, identify what is explicitly asked vs. what you are assuming.
+Work on what was asked. Do not add unrequested refactors or features as side effects.
+
+---
+
+## 5. SELF-CORRECTION
+
+**Rule SC-1 (Failure Logging):**
+When a tool call returns an error, internally note:
+ - What you tried
+ - What the error said
+ - What you believe the root cause is
+Then make exactly ONE targeted fix attempt. If it fails again, escalate (rethink or terminate
+with a clear failure report).
+
+**Rule SC-2 (Success Verification):**
+Never call `terminate` claiming success without verifying the deliverable exists.
+- For files: check `ls` or `read_files`.
+- For API tasks: check the response body.
+- For web tasks: confirm the page title or URL matches the target.
+At least 40% of agent failures in benchmarks are premature terminations where the deliverable
+was never confirmed.
+
+**Rule SC-3 (No Hallucinated Paths):**
+Do not reference file paths, function names, or variable names you have not confirmed exist
+via `read_files`, `glob_search`, or `bash ls`. Always check before referencing.
+
+---
+
+## 6. MEMORY & CONTEXT
+
+**Rule M-1 (Use Workspace Memory):**
+For multi-step or multi-session tasks, use `memory_save` to record key decisions, file
+locations, and partial results. Use `memory_recall` at the start of a new session to recover
+context before doing any fresh work.
+
+**Rule M-2 (Don't Duplicate Workspaces):**
+Check whether a related task was already started in the workspace before creating new files.
+Use `codebase_overview` and `glob_search` to identify existing work. Continue from existing
+progress rather than restarting.
+
+---
+
+## 7. TERMINATION
+
+**Rule T-1 (Structured Termination):**
+Always call `terminate` with all three fields filled:
+ - `status`: "success" | "failure" | "partial"
+ - `summary`: what was actually accomplished (not what was attempted)
+ - `reason`: the specific cause of any failure or blocker
+
+**Rule T-2 (No Silent Exits):**
+Do not end a task by simply stopping tool calls. If the max step limit is approaching, use the
+second-to-last step to call `planning` to summarize state, then `terminate` with a full report.
diff --git a/scripts/export_policy.py b/scripts/export_policy.py
new file mode 100644
index 000000000..da5a9c30f
--- /dev/null
+++ b/scripts/export_policy.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+import argparse
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Export RL policy artifacts into OpenManus runtime layout."
+ )
+ parser.add_argument(
+ "--policy-file",
+ required=True,
+ help="Path to policy markdown text.",
+ )
+ parser.add_argument("--model", default="unknown", help="Model name.")
+ parser.add_argument("--benchmark", default="unknown", help="Benchmark name.")
+ parser.add_argument("--run-id", default="manual", help="Run identifier.")
+ parser.add_argument("--notes", default="", help="Optional notes.")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ root = Path(__file__).resolve().parent.parent
+ source_policy = Path(args.policy_file).resolve()
+ if not source_policy.exists():
+ raise FileNotFoundError(f"Policy file not found: {source_policy}")
+
+ target_dir = root / "research" / "openmanus-rl" / "artifacts" / "policy" / "latest"
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ policy_text = source_policy.read_text(encoding="utf-8").strip()
+ if not policy_text:
+ raise ValueError("Policy file is empty")
+
+ (target_dir / "policy.md").write_text(policy_text + "\n", encoding="utf-8")
+ metadata = {
+ "run_id": args.run_id,
+ "model": args.model,
+ "benchmark": args.benchmark,
+ "notes": args.notes,
+ "exported_at": datetime.now(timezone.utc).isoformat(),
+ "source_policy_file": str(source_policy),
+ }
+ (target_dir / "metadata.json").write_text(
+ json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
+ )
+
+ print(f"Exported policy to: {target_dir / 'policy.md'}")
+ print(f"Exported metadata to: {target_dir / 'metadata.json'}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/local_web_smoke.sh b/scripts/local_web_smoke.sh
new file mode 100755
index 000000000..10e47ea69
--- /dev/null
+++ b/scripts/local_web_smoke.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+SERVER_PID_FILE="/tmp/openmanus_web_smoke_server.pid"
+SERVER_LOG="/tmp/openmanus_web_smoke_server.log"
+
+cleanup() {
+ if [[ -f "$SERVER_PID_FILE" ]]; then
+ kill "$(cat "$SERVER_PID_FILE")" >/dev/null 2>&1 || true
+ rm -f "$SERVER_PID_FILE"
+ fi
+}
+trap cleanup EXIT
+
+if [[ -z "${DATABASE_URL:-}" ]]; then
+ export DATABASE_URL="postgresql+psycopg2://postgres:postgres@localhost:5432/openmanus"
+fi
+
+echo "[smoke] Using DATABASE_URL=$DATABASE_URL"
+
+check_postgres_ready() {
+ if command -v pg_isready >/dev/null 2>&1; then
+ pg_isready -h localhost -U postgres -d openmanus >/dev/null 2>&1
+ return $?
+ fi
+
+ python - <<'PY'
+import os, sys
+import psycopg2
+
+try:
+ conn = psycopg2.connect(
+ host="localhost",
+ port=5432,
+ user="postgres",
+ password="postgres",
+ dbname="openmanus",
+ )
+ conn.close()
+ sys.exit(0)
+except Exception:
+ sys.exit(1)
+PY
+ return $?
+}
+
+echo "[smoke] Checking PostgreSQL readiness..."
+pg_ready=false
+for i in {1..15}; do
+ if check_postgres_ready; then
+ echo "[smoke] PostgreSQL is ready"
+ pg_ready=true
+ break
+ fi
+ sleep 1
+done
+
+if [[ "$pg_ready" != true ]]; then
+ echo "[smoke] PostgreSQL not reachable on localhost:5432, trying to start docker compose service..."
+ docker compose up -d postgres >/dev/null 2>&1 || true
+fi
+
+for i in {1..30}; do
+ if check_postgres_ready; then
+ echo "[smoke] PostgreSQL is ready"
+ pg_ready=true
+ break
+ fi
+ if [[ "$i" -eq 30 ]]; then
+ echo "[smoke] PostgreSQL not ready in time"
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "[smoke] Starting FastAPI server..."
+uvicorn server.api:app --host 0.0.0.0 --port 8000 --workers 1 >"$SERVER_LOG" 2>&1 &
+echo $! >"$SERVER_PID_FILE"
+
+# Wait for server to be ready with retries
+for i in {1..30}; do
+ if curl -sf http://127.0.0.1:8000/api/health >/dev/null 2>&1; then
+ echo "[smoke] Server is ready"
+ break
+ fi
+ echo "[smoke] Waiting for server... attempt $i/30"
+ sleep 1
+done
+
+echo "[smoke] Checking /api/health..."
+for i in {1..10}; do
+ if curl -sf http://127.0.0.1:8000/api/health >/tmp/openmanus_web_smoke_health.json; then
+ cat /tmp/openmanus_web_smoke_health.json
+ break
+ fi
+ if [[ "$i" -eq 10 ]]; then
+ echo "[smoke] Health endpoint not responding"
+ echo "----- server log -----"
+ cat "$SERVER_LOG" || true
+ exit 1
+ fi
+ sleep 2
+done
+
+echo "[smoke] Checking /docs..."
+curl -sf http://127.0.0.1:8000/docs >/tmp/openmanus_web_smoke_docs.html
+grep -i "swagger" /tmp/openmanus_web_smoke_docs.html >/dev/null
+
+echo "[smoke] PASS"
diff --git a/scripts/prepare_deepspec_research.sh b/scripts/prepare_deepspec_research.sh
new file mode 100755
index 000000000..c9cd7a6f3
--- /dev/null
+++ b/scripts/prepare_deepspec_research.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Helper script to prepare DeepSpec research repository
+# This clones or updates DeepSpec into research/deepspec/upstream.
+# It does NOT run training automatically.
+
+CHECKOUT_DIR="research/deepspec/upstream"
+REPO_URL="https://github.com/deepseek-ai/DeepSpec.git"
+
+echo "=== DeepSpec Research Preparation ==="
+echo "Note: DeepSpec requires substantial GPU/storage resources (target caches can be ~38TB)."
+echo "This script only clones or updates the upstream code for future research/evaluation."
+
+mkdir -p "$(dirname "$CHECKOUT_DIR")"
+
+if [ -d "$CHECKOUT_DIR/.git" ]; then
+ echo "Updating existing DeepSpec repository in $CHECKOUT_DIR..."
+ git -C "$CHECKOUT_DIR" pull --ff-only || {
+ echo "Warning: git pull failed, keeping current checkout."
+ }
+else
+ echo "Cloning DeepSpec into $CHECKOUT_DIR..."
+ git clone "$REPO_URL" "$CHECKOUT_DIR"
+fi
+
+echo "DeepSpec repository ready in $CHECKOUT_DIR."
+echo "See research/deepspec/README.md for usage and evaluation workflows."
diff --git a/scripts/sync_everything_claude_code.sh b/scripts/sync_everything_claude_code.sh
new file mode 100755
index 000000000..459aff289
--- /dev/null
+++ b/scripts/sync_everything_claude_code.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TMP_DIR="${TMPDIR:-/tmp}/everything-claude-code-sync"
+SRC_REPO="${1:-https://github.com/affaan-m/everything-claude-code.git}"
+
+rm -rf "$TMP_DIR"
+git clone --depth=1 "$SRC_REPO" "$TMP_DIR"
+
+mkdir -p "$ROOT_DIR/vendor/everything-claude-code"
+rsync -a --delete "$TMP_DIR/skills/" "$ROOT_DIR/vendor/everything-claude-code/skills/"
+rsync -a --delete "$TMP_DIR/agents/" "$ROOT_DIR/vendor/everything-claude-code/agents/"
+rsync -a --delete "$TMP_DIR/.agents/skills/" "$ROOT_DIR/vendor/everything-claude-code/agents-skills/"
+
+echo "Synced skills + agents into vendor/everything-claude-code"
diff --git a/server/api.py b/server/api.py
new file mode 100644
index 000000000..975bcd0c5
--- /dev/null
+++ b/server/api.py
@@ -0,0 +1,3000 @@
+import asyncio
+import hashlib
+import json
+import os
+import re
+import secrets
+import shutil
+import uuid
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Optional
+from urllib import error as urlerror
+from urllib import parse as urlparse
+from urllib import request as urlrequest
+
+import redis.asyncio as aioredis
+import uvicorn
+from fastapi import FastAPI, Form, HTTPException, Request, Response
+from fastapi.middleware.cors import CORSMiddleware
+from sqlalchemy import func, text
+from sse_starlette.sse import EventSourceResponse
+
+from app.config import config
+from app.memory.agentmemory import agentmemory
+from app.sandbox.conversation import ConversationSandbox
+from app.skills import load_skills, select_skills
+from core.task import TaskStatus
+from core.task_registry import TaskRegistry
+from server.celery_app import celery_app
+from server.models import (
+ AppSettingORM,
+ ConversationEventORM,
+ ConversationORM,
+ ObsidianEdgeORM,
+ ObsidianNoteORM,
+ SessionORM,
+ TaskORM,
+ UserORM,
+)
+from server.tasks import run_task
+
+
+REDIS_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
+DEFAULT_CONVERSATION_ID = os.getenv("OPENMANUS_DEFAULT_CONVERSATION_ID", "main")
+SESSION_COOKIE = "openmanus_session"
+SESSION_DAYS = int(os.getenv("OPENMANUS_SESSION_DAYS", "30"))
+WORKSPACE_ROOT = os.getenv("OPENMANUS_WORKSPACE_ROOT", "/app/workspace")
+HOST_WORKSPACE_ROOT = os.getenv(
+ "OPENMANUS_HOST_WORKSPACE_ROOT", "/home/mohamed/OpenManus/workspace"
+)
+
+
+app = FastAPI(title="OpenManus Task API", version="0.1.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+registry = TaskRegistry()
+
+
+def _ensure_schema_updates() -> None:
+ from server.models import Base
+
+ Base.metadata.create_all(bind=registry.engine)
+ with registry.engine.begin() as connection:
+ connection.execute(
+ text("ALTER TABLE conversations ADD COLUMN IF NOT EXISTS model VARCHAR")
+ )
+ connection.execute(
+ text(
+ "ALTER TABLE conversations ADD COLUMN IF NOT EXISTS settings JSONB NOT NULL DEFAULT '{}'::jsonb"
+ )
+ )
+ connection.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS ix_conversation_events_conversation_created "
+ "ON conversation_events (conversation_id, created_at, event_id)"
+ )
+ )
+
+
+AVAILABLE_TOOLS = [
+ {"name": "skill_playbook", "label": "Skill Playbook", "scope": "reasoning"},
+ {"name": "planning", "label": "Planning", "scope": "reasoning"},
+ {"name": "codebase_overview", "label": "Codebase Overview", "scope": "code"},
+ {"name": "glob", "label": "Glob Search", "scope": "code"},
+ {"name": "grep", "label": "Grep Search", "scope": "code"},
+ {"name": "read_files", "label": "Read Files", "scope": "code"},
+ {"name": "python_execute", "label": "Python", "scope": "terminal"},
+ {"name": "bash", "label": "Bash", "scope": "terminal"},
+ {"name": "browser_use", "label": "Browser", "scope": "browser"},
+ {"name": "web_search", "label": "Web Search", "scope": "browser"},
+ {"name": "apply_patch_editor", "label": "Patch Editor", "scope": "code"},
+ {"name": "memory_save", "label": "Memory Save", "scope": "memory"},
+ {"name": "memory_recall", "label": "Memory Recall", "scope": "memory"},
+ {"name": "ask_human", "label": "Ask Human", "scope": "conversation"},
+ {"name": "wait_for_user_input", "label": "Wait For Input", "scope": "conversation"},
+ {"name": "terminate", "label": "Terminate", "scope": "control", "locked": True},
+]
+
+CONVERSATION_STATES = {
+ "CREATED": "running",
+ "RUNNING": "running",
+ "COMPLETED": "finished",
+ "FAILED": "error",
+ "INTERRUPTED": "paused",
+}
+
+
+_ensure_schema_updates()
+
+
+TERMINAL_STATUSES = {"COMPLETED", "FAILED", "INTERRUPTED"}
+WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[^\]]*)\]\]")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _hash_password(password: str, salt: Optional[str] = None) -> str:
+ salt = salt or secrets.token_hex(16)
+ iterations = 210_000
+ digest = hashlib.pbkdf2_hmac(
+ "sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations
+ ).hex()
+ return f"pbkdf2_sha256${iterations}${salt}${digest}"
+
+
+def _verify_password(password: str, stored: str) -> bool:
+ try:
+ scheme, iterations, salt, digest = stored.split("$", 3)
+ if scheme != "pbkdf2_sha256":
+ return False
+ check = hashlib.pbkdf2_hmac(
+ "sha256", password.encode("utf-8"), bytes.fromhex(salt), int(iterations)
+ ).hex()
+ return secrets.compare_digest(check, digest)
+ except Exception:
+ return False
+
+
+def _session_token_from_request(request: Request) -> Optional[str]:
+ token = request.cookies.get(SESSION_COOKIE)
+ if token:
+ return token
+ auth = request.headers.get("authorization", "")
+ if auth.lower().startswith("bearer "):
+ return auth.split(" ", 1)[1].strip()
+ return None
+
+
+def _public_user(user: UserORM) -> dict:
+ return {
+ "id": str(user.user_id),
+ "email": user.email,
+ "name": user.name,
+ "role": user.role,
+ }
+
+
+def _require_admin(request: Request) -> UserORM:
+ user = _require_user(request)
+ if user.role != "admin":
+ raise HTTPException(status_code=403, detail="Admin access required")
+ return user
+
+
+def _require_user(request: Request) -> UserORM:
+ token = _session_token_from_request(request)
+ if not token:
+ raise HTTPException(status_code=401, detail="Not signed in")
+ with registry.SessionLocal() as session:
+ session_orm = session.get(SessionORM, token)
+ if session_orm is None or session_orm.expires_at <= _now():
+ raise HTTPException(status_code=401, detail="Session expired")
+ user = session.get(UserORM, session_orm.user_id)
+ if user is None:
+ raise HTTPException(status_code=401, detail="User not found")
+ session.expunge(user)
+ return user
+
+
+def _create_session(response: Response, user_id: uuid.UUID) -> str:
+ token = secrets.token_urlsafe(32)
+ expires_at = _now() + timedelta(days=SESSION_DAYS)
+ with registry.SessionLocal() as session:
+ session.add(SessionORM(token=token, user_id=user_id, expires_at=expires_at))
+ session.commit()
+ response.set_cookie(
+ SESSION_COOKIE,
+ token,
+ max_age=SESSION_DAYS * 24 * 60 * 60,
+ httponly=True,
+ samesite="lax",
+ path="/",
+ )
+ return token
+
+
+def _conversation_to_dict(session, conversation: ConversationORM) -> dict:
+ from sqlalchemy import desc
+
+ latest_task = (
+ session.query(TaskORM)
+ .filter(
+ TaskORM.input["conversation_id"].astext == str(conversation.conversation_id)
+ )
+ .order_by(desc(TaskORM.created_at))
+ .first()
+ )
+ latest_status = latest_task.status if latest_task else None
+ settings = conversation.settings or {}
+ requested_context_window = settings.get("requested_context_window")
+ auto_context_compress = settings.get("auto_context_compress", True)
+ if requested_context_window not in (None, ""):
+ try:
+ requested_context_window = int(requested_context_window)
+ except (TypeError, ValueError):
+ requested_context_window = None
+ current_input = 0
+ if latest_task is not None:
+ latest_token_event = (
+ session.query(ConversationEventORM)
+ .filter(
+ ConversationEventORM.task_id == latest_task.task_id,
+ ConversationEventORM.event_type == "token_count",
+ )
+ .order_by(desc(ConversationEventORM.created_at))
+ .first()
+ )
+ if latest_token_event is not None:
+ payload = latest_token_event.payload or {}
+ current_input = payload.get("total_input") or payload.get("input") or 0
+ try:
+ current_input = int(current_input)
+ except (TypeError, ValueError):
+ current_input = 0
+ ratio = (
+ round(current_input / requested_context_window, 4)
+ if requested_context_window and requested_context_window > 0
+ else None
+ )
+ # Effective context window loaded for the current conversation model.
+ # Priority:
+ # 0) persisted LM Studio load result in conversation settings
+ # 1) admin llm_connection.max_input_tokens when model matches or no explicit model is set
+ # 2) configured model entry max_input_tokens
+ # 3) configured default max_input_tokens
+ received_context_window = None
+ try:
+ persisted_received = settings.get("received_context_window")
+ if persisted_received not in (None, ""):
+ received_context_window = int(persisted_received)
+
+ selected_model = str(conversation.model or "").strip() or None
+ connection = _get_app_setting(session, "llm_connection", {})
+ if isinstance(connection, dict):
+ conn_model = str(connection.get("model") or "").strip() or None
+ conn_window = connection.get("max_input_tokens")
+ if conn_window not in (None, "") and (
+ selected_model is None or selected_model == conn_model
+ ):
+ received_context_window = int(conn_window)
+
+ if (
+ received_context_window in (None, 0)
+ and selected_model
+ and isinstance(config.llm, dict)
+ ):
+ for llm_settings in config.llm.values():
+ if getattr(llm_settings, "model", None) == selected_model:
+ value = getattr(llm_settings, "max_input_tokens", None)
+ if value not in (None, 0):
+ received_context_window = int(value)
+ break
+
+ if received_context_window in (None, 0):
+ default_llm = (
+ config.llm.get("default") if isinstance(config.llm, dict) else None
+ )
+ value = (
+ getattr(default_llm, "max_input_tokens", None) if default_llm else None
+ )
+ if value not in (None, 0):
+ received_context_window = int(value)
+ except Exception:
+ received_context_window = None
+
+ latest_context = {
+ "requested_window": requested_context_window,
+ "received_window": received_context_window,
+ "received_window_source": settings.get("received_context_window_source")
+ or ("fallback_inferred" if received_context_window else None),
+ "current_input_tokens": current_input,
+ "usage_ratio": ratio,
+ "is_near_limit": bool(ratio is not None and ratio >= 0.9),
+ "auto_context_compress": bool(auto_context_compress),
+ }
+ return {
+ "id": str(conversation.conversation_id),
+ "conversation_id": str(conversation.conversation_id),
+ "title": conversation.title,
+ "model": conversation.model,
+ "settings": settings,
+ "context": latest_context,
+ "latest_task_id": str(latest_task.task_id) if latest_task else None,
+ "latest_status": latest_status,
+ "state": CONVERSATION_STATES.get(str(latest_status or ""), "idle"),
+ "updated_at": conversation.updated_at.isoformat()
+ if conversation.updated_at
+ else None,
+ "created_at": conversation.created_at.isoformat()
+ if conversation.created_at
+ else None,
+ }
+
+
+def _task_to_dict(orm: TaskORM) -> dict:
+ status = str(orm.status)
+ return {
+ "id": str(orm.task_id),
+ "task_id": str(orm.task_id),
+ "status": status,
+ "state": CONVERSATION_STATES.get(status, "idle"),
+ "result": orm.result,
+ "conversation_id": _conversation_id_for(orm),
+ "created_at": orm.created_at.isoformat() if orm.created_at else None,
+ "request": (orm.input or {}).get("prompt", "Untitled task"),
+ }
+
+
+def _persist_conversation_event(
+ session,
+ conversation_id: str,
+ event_type: str,
+ payload: dict,
+ task_id: Optional[str] = None,
+) -> ConversationEventORM:
+ try:
+ cid = uuid.UUID(str(conversation_id))
+ tid = uuid.UUID(str(task_id)) if task_id else None
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid event id") from exc
+ event = ConversationEventORM(
+ conversation_id=cid,
+ task_id=tid,
+ event_type=event_type,
+ payload=payload,
+ )
+ session.add(event)
+ session.flush()
+ return event
+
+
+def _conversation_tasks(
+ session, conversation_id: str, ascending: bool = True
+) -> list[TaskORM]:
+ from sqlalchemy import asc, desc
+
+ order = asc(TaskORM.created_at) if ascending else desc(TaskORM.created_at)
+ return (
+ session.query(TaskORM)
+ .filter(TaskORM.input["conversation_id"].astext == str(conversation_id))
+ .order_by(order)
+ .all()
+ )
+
+
+def _conversation_sandbox(conversation_id: str) -> ConversationSandbox:
+ return ConversationSandbox(
+ conversation_id=conversation_id,
+ host_workspace=Path(HOST_WORKSPACE_ROOT) / "conversations" / conversation_id,
+ config=config.sandbox,
+ )
+
+
+def _obsidian_health(session, conversation_id: Optional[str] = None) -> dict:
+ payload = {
+ "enabled": True,
+ "available": True,
+ "live": False,
+ "reason": "No notes found yet",
+ "note_count": 0,
+ }
+ try:
+ query = session.query(func.count(ObsidianNoteORM.note_id))
+ if conversation_id:
+ query = query.filter(
+ ObsidianNoteORM.conversation_id == uuid.UUID(str(conversation_id))
+ )
+ count = int(query.scalar() or 0)
+ payload["note_count"] = count
+ if count > 0:
+ payload["live"] = True
+ payload["reason"] = "Notes indexed"
+ except Exception as exc:
+ payload["available"] = False
+ payload["live"] = False
+ payload["reason"] = f"DB error: {exc}"
+ return payload
+
+
+def _agentmemory_health(conversation_id: Optional[str] = None) -> dict:
+ vec_health = agentmemory.get_vector_health()
+ payload = {
+ "enabled": bool(config.agentmemory.enabled),
+ "available": False,
+ "live": False,
+ "reason": "Disabled",
+ "base_url": "local_sqlite",
+ "project": config.agentmemory.project,
+ **vec_health,
+ }
+ if not config.agentmemory.enabled:
+ return payload
+
+ try:
+ # Check SQLite DB health by attempting to connect and query
+ with agentmemory._get_conn() as conn:
+ conn.execute("SELECT 1 FROM memories LIMIT 1")
+ payload["available"] = True
+ payload["live"] = True
+ payload["reason"] = "Live (Local SQLite FTS5)"
+ except Exception as exc:
+ payload["available"] = False
+ payload["live"] = False
+ payload["reason"] = f"SQLite failed: {exc}"
+
+ if payload["live"] and conversation_id:
+ # Optional lightweight probe for conversation-specific recall viability.
+ try:
+ hits = agentmemory.search(
+ conversation_id=conversation_id, query="summary", limit=1
+ )
+ payload["conversation_hits"] = len(hits)
+ except Exception:
+ payload["conversation_hits"] = 0
+
+ payload.update(agentmemory.get_vector_health())
+ return payload
+
+
+def _llm_connection_health(session) -> dict:
+ connection = _effective_llm_connection(session)
+ payload = {
+ "configured": bool(connection),
+ "live": False,
+ "reason": "Not configured",
+ "api_type": str(connection.get("api_type") or ""),
+ "base_url": str(connection.get("base_url") or ""),
+ }
+ base_url = str(connection.get("base_url") or "").strip()
+ if not base_url:
+ return payload
+ api_type = str(connection.get("api_type") or "").strip().lower()
+ token = str(connection.get("api_key") or "").strip() or None
+ try:
+ if api_type in {"lmstudio", "local"}:
+ native = _lmstudio_native_base(base_url)
+ if not native:
+ payload["reason"] = "Invalid LM Studio URL"
+ return payload
+ data = _http_json("GET", f"{native}/models", token=token)
+ else:
+ models_url = (
+ base_url.rstrip("/") + "/models"
+ if base_url.rstrip("/").endswith("/v1")
+ else base_url.rstrip("/") + "/v1/models"
+ )
+ data = _http_json("GET", models_url, token=token)
+ rows = _extract_model_rows(data)
+ payload["live"] = True
+ payload["reason"] = f"OK ({len(rows)} models)"
+ payload["model_count"] = len(rows)
+ return payload
+ except Exception as exc:
+ payload["live"] = False
+ payload["reason"] = str(exc)
+ return payload
+
+
+def _prune_orphan_conversation_sandboxes(session) -> int:
+ """Remove sandbox containers whose conversation row no longer exists."""
+ try:
+ import docker
+
+ active_ids = {
+ str(row[0]) for row in session.query(ConversationORM.conversation_id).all()
+ }
+ client = docker.from_env()
+ removed = 0
+ for container in client.containers.list(
+ all=True, filters={"label": "openmanus.kind=conversation-sandbox"}
+ ):
+ conversation_id = container.labels.get("openmanus.conversation_id")
+ if conversation_id and conversation_id not in active_ids:
+ container.remove(force=True)
+ removed += 1
+ return removed
+ except Exception:
+ return 0
+
+
+def _ensure_default_conversation(session, user_id: uuid.UUID) -> ConversationORM:
+ from sqlalchemy import asc
+
+ conversation = (
+ session.query(ConversationORM)
+ .filter(ConversationORM.user_id == user_id)
+ .order_by(asc(ConversationORM.created_at))
+ .first()
+ )
+ if conversation is not None:
+ return conversation
+ conversation = ConversationORM(user_id=user_id, title="New conversation")
+ session.add(conversation)
+ session.flush()
+ return conversation
+
+
+def _get_app_setting(session, key: str, default: Any) -> Any:
+ setting = session.get(AppSettingORM, key)
+ if setting is None:
+ return default
+ return setting.value
+
+
+def _set_app_setting(session, key: str, value: Any) -> None:
+ setting = session.get(AppSettingORM, key)
+ if setting is None:
+ session.add(AppSettingORM(key=key, value=value))
+ else:
+ setting.value = value
+
+
+def _redact_config(value: Any) -> Any:
+ secret_key_names = {
+ "api_key",
+ "apikey",
+ "password",
+ "secret",
+ "access_token",
+ "refresh_token",
+ "bearer_token",
+ "authorization",
+ "auth_token",
+ }
+ if isinstance(value, dict):
+ redacted = {}
+ for key, item in value.items():
+ lowered = str(key).lower()
+ if lowered in secret_key_names or lowered.endswith("_api_key"):
+ redacted[key] = "********" if item else item
+ else:
+ redacted[key] = _redact_config(item)
+ return redacted
+ if isinstance(value, list):
+ return [_redact_config(item) for item in value]
+ return value
+
+
+def _loaded_config_defaults() -> dict:
+ data = config._config.model_dump(mode="json")
+ data["server"] = {
+ "database_url": os.getenv("DATABASE_URL", ""),
+ "redis_url": REDIS_URL,
+ "workspace_root": WORKSPACE_ROOT,
+ "single_conversation": os.getenv("OPENMANUS_SINGLE_CONVERSATION", "false"),
+ "default_conversation_id": DEFAULT_CONVERSATION_ID,
+ "session_days": SESSION_DAYS,
+ }
+ return _redact_config(data)
+
+
+def _default_llm_connection() -> dict:
+ default = config.llm.get("default")
+ if default is None:
+ return {}
+ return _redact_config(default.model_dump(mode="json"))
+
+
+def _effective_llm_connection(session) -> dict:
+ override = _get_app_setting(session, "llm_connection", {})
+ if isinstance(override, dict) and override.get("base_url"):
+ return override
+ default = config.llm.get("default")
+ return default.model_dump(mode="json") if default is not None else {}
+
+
+def _lmstudio_native_base(base_url: str) -> Optional[str]:
+ try:
+ parsed = urlparse.urlparse(base_url.strip())
+ except Exception:
+ return None
+ if not parsed.scheme or not parsed.netloc:
+ return None
+ root = f"{parsed.scheme}://{parsed.netloc}"
+ return f"{root}/api/v1"
+
+
+def _http_json(
+ method: str,
+ url: str,
+ payload: Optional[dict] = None,
+ token: Optional[str] = None,
+ timeout: int = 8,
+) -> dict:
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
+ req = urlrequest.Request(url, method=method, headers=headers, data=body)
+ with urlrequest.urlopen(req, timeout=timeout) as resp:
+ data = resp.read()
+ if not data:
+ return {}
+ return json.loads(data.decode("utf-8"))
+
+
+def _extract_model_rows(payload: Any) -> list[dict]:
+ if isinstance(payload, list):
+ return [item for item in payload if isinstance(item, dict)]
+ if not isinstance(payload, dict):
+ return []
+ data_rows = payload.get("data")
+ if isinstance(data_rows, list):
+ return [item for item in data_rows if isinstance(item, dict)]
+ model_rows = payload.get("models")
+ if isinstance(model_rows, list):
+ return [item for item in model_rows if isinstance(item, dict)]
+ return []
+
+
+def _model_id_from_row(item: dict) -> str:
+ return str(
+ item.get("id")
+ or item.get("key")
+ or item.get("model")
+ or item.get("name")
+ or item.get("instance_id")
+ or ""
+ ).strip()
+
+
+def _model_instance_id_from_row(item: dict) -> str:
+ loaded_instances = item.get("loaded_instances")
+ if isinstance(loaded_instances, list) and loaded_instances:
+ first = loaded_instances[0]
+ if isinstance(first, dict):
+ instance_id = str(first.get("id") or "").strip()
+ if instance_id:
+ return instance_id
+ return str(item.get("instance_id") or "").strip()
+
+
+def _model_state_from_row(item: dict) -> str:
+ state = str(item.get("state") or "").strip().lower()
+ if state:
+ return state
+ loaded_instances = item.get("loaded_instances")
+ if isinstance(loaded_instances, list):
+ return "loaded" if len(loaded_instances) > 0 else "not-loaded"
+ return ""
+
+
+def _model_variant_tag_from_row(item: dict) -> str:
+ quant = item.get("quantization")
+ if isinstance(quant, dict):
+ name = str(quant.get("name") or "").strip()
+ if name:
+ return name
+ params = str(item.get("params_string") or "").strip()
+ if params:
+ return params
+ fmt = str(item.get("format") or "").strip()
+ if fmt:
+ return fmt
+ return ""
+
+
+def _require_conversation(
+ session, user_id: uuid.UUID, conversation_id: str
+) -> ConversationORM:
+ try:
+ cid = uuid.UUID(str(conversation_id))
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid conversation id")
+ conversation = session.get(ConversationORM, cid)
+ if conversation is None or conversation.user_id != user_id:
+ raise HTTPException(status_code=404, detail="Conversation not found")
+ return conversation
+
+
+def _task_belongs_to_user(session, orm: TaskORM, user_id: uuid.UUID) -> bool:
+ conversation_id = (orm.input or {}).get("conversation_id")
+ if not conversation_id:
+ return False
+ try:
+ conversation = session.get(ConversationORM, uuid.UUID(str(conversation_id)))
+ except ValueError:
+ return False
+ return conversation is not None and conversation.user_id == user_id
+
+
+def _extract_wikilinks(content: str) -> list[str]:
+ if not content:
+ return []
+ out: list[str] = []
+ for match in WIKILINK_RE.findall(content):
+ normalized = str(match).strip()
+ if normalized:
+ out.append(normalized)
+ return out
+
+
+def _obsidian_graph_payload(session, conversation: ConversationORM) -> dict:
+ notes = (
+ session.query(ObsidianNoteORM)
+ .filter(ObsidianNoteORM.conversation_id == conversation.conversation_id)
+ .all()
+ )
+ edges = (
+ session.query(ObsidianEdgeORM)
+ .filter(ObsidianEdgeORM.conversation_id == conversation.conversation_id)
+ .all()
+ )
+ node_payload = [
+ {
+ "id": str(note.note_id),
+ "path": note.path,
+ "title": note.title,
+ "tags": note.tags or [],
+ "updated_at": note.updated_at.isoformat() if note.updated_at else None,
+ }
+ for note in notes
+ ]
+ edge_payload = [
+ {
+ "id": str(edge.edge_id),
+ "source": str(edge.source_note_id),
+ "target": str(edge.target_note_id),
+ "relation": edge.relation,
+ }
+ for edge in edges
+ ]
+ return {
+ "nodes": node_payload,
+ "edges": edge_payload,
+ "node_count": len(node_payload),
+ "edge_count": len(edge_payload),
+ }
+
+
+def _agent_event_to_progress(event: dict) -> list[dict]:
+ """
+ Convert one internal agent event into one or more SSE progress messages
+ that match the frontend's lifecycle type hierarchy.
+
+ Internal types → frontend lifecycle names:
+ step_start → [agent:lifecycle:start (once)] + agent:lifecycle:step:start
+ + agent:lifecycle:step:think:start
+ thought → agent:lifecycle:step:think:tool:selected
+ + agent:lifecycle:step:think:complete
+ + agent:lifecycle:step:act:start
+ tool_result → agent:lifecycle:step:act:tool:execute:complete
+ + agent:lifecycle:step:act:complete
+ + agent:lifecycle:step:complete
+ finish_signal → agent:lifecycle:complete
+ final_response → agent:lifecycle:complete
+ terminated → agent:lifecycle:terminated
+ browser_screenshot → agent:lifecycle:step:think:browser:browse:complete
+ token_count → agent:lifecycle:step:think:token:count
+ context_compressed → agent:lifecycle:step:think:context:compressed
+ terminal_output → agent:lifecycle:step:act:tool:terminal:output
+ workspace_file_updated → agent:lifecycle:step:act:tool:file:updated
+ error → agent:lifecycle:step:error
+ """
+ agent_type = event.get("type", "")
+ data = event.get("data", {})
+
+ def _msg(name: str, content=None) -> dict:
+ return {"type": "progress", "name": name, "content": content or data}
+
+ if agent_type == "step_start":
+ step = data.get("step", 1)
+ msgs = []
+ if step == 1:
+ msgs.append(_msg("agent:lifecycle:start", {"step": step}))
+ msgs.append(_msg("agent:lifecycle:step:start", data))
+ msgs.append(_msg("agent:lifecycle:step:think:start", data))
+ return msgs
+
+ if agent_type == "thought":
+ tools = data.get("tools", [])
+ tool_calls = data.get("tool_calls", [])
+ msgs = [
+ _msg(
+ "agent:lifecycle:step:think:tool:selected",
+ {
+ "tool": (
+ (tool_calls[0].get("function", {}) or {}).get("name")
+ if tool_calls
+ else (tools[0] if tools else None)
+ ),
+ "tool_calls": tool_calls,
+ "content": data.get("content", ""),
+ },
+ )
+ ]
+ msgs.append(_msg("agent:lifecycle:step:think:complete", data))
+ if tools or tool_calls:
+ msgs.append(_msg("agent:lifecycle:step:act:start", data))
+ if tool_calls:
+ for call in tool_calls:
+ fn = call.get("function", {}) or {}
+ call_id = call.get("id") or fn.get("name")
+ call_name = fn.get("name")
+ call_args = fn.get("arguments")
+ msgs.append(
+ _msg(
+ "agent:lifecycle:step:act:tool:start",
+ {
+ "id": call_id,
+ "name": call_name,
+ },
+ )
+ )
+ msgs.append(
+ _msg(
+ "agent:lifecycle:step:act:tool:execute:start",
+ {
+ "id": call_id,
+ "name": call_name,
+ "arguments": call_args,
+ },
+ )
+ )
+ else:
+ first_id = tools[0] if tools else None
+ first_name = tools[0] if tools else None
+ msgs.append(
+ _msg(
+ "agent:lifecycle:step:act:tool:start",
+ {
+ "id": first_id,
+ "name": first_name,
+ },
+ )
+ )
+ msgs.append(
+ _msg(
+ "agent:lifecycle:step:act:tool:execute:start",
+ {
+ "id": first_id,
+ "name": first_name,
+ "arguments": data.get("arguments"),
+ },
+ )
+ )
+ return msgs
+
+ if agent_type == "tool_result":
+ tool = data.get("tool", "")
+ tool_call_id = data.get("tool_call_id") or tool
+ msgs = [
+ _msg(
+ "agent:lifecycle:step:act:tool:execute:complete",
+ {
+ "id": tool_call_id,
+ "name": tool,
+ "result": data.get("result", ""),
+ },
+ )
+ ]
+ msgs.append(
+ _msg(
+ "agent:lifecycle:step:act:tool:complete",
+ {"id": tool_call_id, "name": tool},
+ )
+ )
+ msgs.append(_msg("agent:lifecycle:step:act:complete", data))
+ msgs.append(_msg("agent:lifecycle:step:complete", data))
+ return msgs
+
+ if agent_type == "step_result":
+ # Secondary step completion — already handled via tool_result path; skip
+ return []
+
+ if agent_type == "finish_signal":
+ # Only map the final completion from tasks.py (which contains 'workspace')
+ # to avoid duplicating the final message that is already shown in the 'thought' bubble.
+ if "workspace" in data:
+ return [_msg("agent:lifecycle:complete", data)]
+ return []
+
+ if agent_type == "final_response":
+ # Ignore since the assistant text is already in the 'thought' event,
+ # and tasks.py will emit a final finish_signal anyway.
+ return []
+
+ if agent_type == "browser_screenshot":
+ return [_msg("agent:lifecycle:step:think:browser:browse:complete", data)]
+
+ if agent_type == "token_count":
+ return [_msg("agent:lifecycle:step:think:token:count", data)]
+
+ if agent_type == "context_compressed":
+ return [_msg("agent:lifecycle:step:think:context:compressed", data)]
+
+ if agent_type == "terminal_output":
+ return [_msg("agent:lifecycle:step:act:tool:terminal:output", data)]
+
+ if agent_type == "workspace_file_updated":
+ return [_msg("agent:lifecycle:step:act:tool:file:updated", data)]
+
+ if agent_type == "terminated":
+ return [_msg("agent:lifecycle:terminated", data)]
+
+ if agent_type == "agent_state":
+ return [_msg("agent:lifecycle:state:change", data)]
+
+ if agent_type == "stuck_detected":
+ return [_msg("agent:lifecycle:state:change", data)]
+
+ if agent_type == "error":
+ msgs = [_msg("agent:lifecycle:step:error", data)]
+ if data.get("fatal", True):
+ msgs.append(
+ _msg(
+ "agent:lifecycle:terminated",
+ {
+ **data,
+ "reason": data.get("detail")
+ or data.get("message")
+ or "Task failed",
+ "status": "failure",
+ },
+ )
+ )
+ return msgs
+
+ # Catch-all: pass through as a generic step event
+ return [_msg(f"agent:lifecycle:{agent_type}", data)]
+
+
+def _task_input(
+ prompt: Optional[str],
+ conversation_id: str,
+ parent_task_id: Optional[str] = None,
+ model: Optional[str] = None,
+ disabled_tools: Optional[list[str]] = None,
+ requested_context_window: Optional[int] = None,
+ auto_context_compress: Optional[bool] = None,
+ disabled_skills: Optional[list[str]] = None,
+ enable_vendor_skills: Optional[bool] = None,
+ pinned_skills: Optional[list[str]] = None,
+ identity_notes: Optional[str] = None,
+) -> dict:
+ data = {"conversation_id": conversation_id}
+ if prompt:
+ data["prompt"] = prompt
+ if parent_task_id:
+ data["parent_task_id"] = parent_task_id
+ if model:
+ data["model"] = model
+ if disabled_tools:
+ data["disabled_tools"] = disabled_tools
+ if requested_context_window and requested_context_window > 0:
+ data["requested_context_window"] = int(requested_context_window)
+ if auto_context_compress is not None:
+ data["auto_context_compress"] = bool(auto_context_compress)
+ if disabled_skills:
+ data["disabled_skills"] = [
+ str(name) for name in disabled_skills if str(name).strip()
+ ]
+ if enable_vendor_skills is not None:
+ data["enable_vendor_skills"] = bool(enable_vendor_skills)
+ if pinned_skills:
+ data["pinned_skills"] = [
+ str(name).strip() for name in pinned_skills if str(name).strip()
+ ]
+ if identity_notes:
+ data["identity_notes"] = str(identity_notes).strip()
+ return data
+
+
+def _conversation_id_for(orm: TaskORM) -> str:
+ task_input = orm.input or {}
+ if task_input.get("conversation_id"):
+ return str(task_input.get("conversation_id"))
+ if os.getenv("OPENMANUS_SINGLE_CONVERSATION", "true").lower() != "false":
+ return DEFAULT_CONVERSATION_ID
+ return str(task_input.get("conversation_id") or DEFAULT_CONVERSATION_ID)
+
+
+# ---------------------------------------------------------------------------
+# Auth and conversations
+# ---------------------------------------------------------------------------
+
+
+@app.post("/api/auth/signup")
+async def signup(request: Request, response: Response):
+ body = await request.json()
+ email = str(body.get("email") or "").strip().lower()
+ password = str(body.get("password") or "")
+ name = str(body.get("name") or email.split("@")[0] or "User").strip()
+ if not email or "@" not in email:
+ raise HTTPException(status_code=400, detail="Valid email is required")
+ if len(password) < 8:
+ raise HTTPException(
+ status_code=400, detail="Password must be at least 8 characters"
+ )
+
+ with registry.SessionLocal() as session:
+ existing = session.query(UserORM).filter(UserORM.email == email).first()
+ if existing is not None:
+ raise HTTPException(status_code=409, detail="Email is already registered")
+ user_count = session.query(UserORM).count()
+ role = "admin" if user_count == 0 else "user"
+ user = UserORM(
+ email=email,
+ name=name,
+ password_hash=_hash_password(password),
+ role=role,
+ )
+ session.add(user)
+ session.flush()
+ _ensure_default_conversation(session, user.user_id)
+ session.commit()
+ public = _public_user(user)
+ user_id = user.user_id
+ token = _create_session(response, user_id)
+ return {"user": public, "token": token}
+
+
+@app.post("/api/auth/login")
+async def login(request: Request, response: Response):
+ body = await request.json()
+ email = str(body.get("email") or "").strip().lower()
+ password = str(body.get("password") or "")
+ with registry.SessionLocal() as session:
+ user = session.query(UserORM).filter(UserORM.email == email).first()
+ if user is None or not _verify_password(password, user.password_hash):
+ raise HTTPException(status_code=401, detail="Invalid email or password")
+ public = _public_user(user)
+ user_id = user.user_id
+ token = _create_session(response, user_id)
+ return {"user": public, "token": token}
+
+
+@app.post("/api/auth/logout")
+async def logout(request: Request, response: Response):
+ token = _session_token_from_request(request)
+ if token:
+ with registry.SessionLocal() as session:
+ session_orm = session.get(SessionORM, token)
+ if session_orm is not None:
+ session.delete(session_orm)
+ session.commit()
+ response.delete_cookie(SESSION_COOKIE, path="/")
+ return {"ok": True}
+
+
+@app.get("/api/auth/me")
+async def me(request: Request):
+ user = _require_user(request)
+ return {"user": _public_user(user)}
+
+
+@app.get("/api/models")
+async def list_models(request: Request):
+ _require_user(request)
+ with registry.SessionLocal() as session:
+ connection = _effective_llm_connection(session)
+
+ configured = [
+ {
+ "id": settings.model,
+ "name": name,
+ "api_type": settings.api_type,
+ "base_model": settings.model,
+ "variant_tag": "",
+ "raw_model_key": settings.model,
+ }
+ for name, settings in config.llm.items()
+ if settings.model
+ ]
+ if isinstance(connection, dict) and connection.get("model"):
+ configured.insert(
+ 0,
+ {
+ "id": connection["model"],
+ "name": "admin",
+ "api_type": connection.get("api_type", "openai"),
+ "base_model": connection["model"],
+ "variant_tag": "",
+ "raw_model_key": connection["model"],
+ },
+ )
+ models = configured
+
+ # Enrich with live LM Studio model variants when connected.
+ try:
+ base_url = str(connection.get("base_url") or "")
+ api_type = str(connection.get("api_type") or "").lower()
+ api_key = str(connection.get("api_key") or "")
+ native_base = _lmstudio_native_base(base_url)
+ if native_base and (
+ api_type in {"openai", "lmstudio", "local"}
+ or "1234" in base_url
+ or "lmstudio" in base_url.lower()
+ ):
+ listing = _http_json("GET", f"{native_base}/models", token=api_key or None)
+ lm_models = listing.get("data") if isinstance(listing, dict) else []
+ if isinstance(lm_models, list):
+ for item in lm_models:
+ if not isinstance(item, dict):
+ continue
+ model_id = _model_id_from_row(item)
+ if not model_id:
+ continue
+ models.insert(
+ 0,
+ {
+ "id": model_id,
+ "name": item.get("display_name")
+ or item.get("path")
+ or item.get("name")
+ or "lmstudio",
+ "api_type": "lmstudio",
+ "state": _model_state_from_row(item),
+ "instance_id": _model_instance_id_from_row(item),
+ "base_model": str(item.get("key") or model_id),
+ "variant_tag": _model_variant_tag_from_row(item),
+ "raw_model_key": str(item.get("key") or model_id),
+ },
+ )
+ except Exception:
+ # Keep model listing resilient if LM Studio native API is unavailable.
+ pass
+
+ seen = set()
+ unique_models = []
+ for model in models:
+ if model["id"] in seen:
+ continue
+ seen.add(model["id"])
+ unique_models.append(model)
+ return {"models": unique_models}
+
+
+@app.post("/api/models/query")
+async def query_models(request: Request):
+ _require_user(request)
+ body = (
+ await request.json()
+ if request.headers.get("content-type", "").startswith("application/json")
+ else {}
+ )
+ host = str((body or {}).get("host") or "").strip()
+ api_key = str((body or {}).get("api_key") or "").strip() or None
+ style = str((body or {}).get("style") or "custom").strip().lower()
+ models_path = str((body or {}).get("models_path") or "").strip()
+
+ if not host:
+ raise HTTPException(status_code=400, detail="Host is required")
+
+ models: list[dict] = []
+ url = ""
+ try:
+ if style == "lm-studio":
+ native = _lmstudio_native_base(host)
+ if not native:
+ raise HTTPException(
+ status_code=400, detail="Invalid LM Studio host URL"
+ )
+ url = f"{native}/models"
+ data = _http_json("GET", url, token=api_key, timeout=8)
+ rows = _extract_model_rows(data)
+ for item in rows:
+ if not isinstance(item, dict):
+ continue
+ model_id = _model_id_from_row(item)
+ if not model_id:
+ continue
+ models.append(
+ {
+ "id": model_id,
+ "name": item.get("display_name")
+ or item.get("path")
+ or item.get("name")
+ or model_id,
+ "api_type": "lmstudio",
+ "state": _model_state_from_row(item),
+ "instance_id": _model_instance_id_from_row(item),
+ "base_model": str(item.get("key") or model_id),
+ "variant_tag": _model_variant_tag_from_row(item),
+ "raw_model_key": str(item.get("key") or model_id),
+ }
+ )
+ elif style == "ollama":
+ # Prefer OpenAI-compatible endpoint, fallback to Ollama native tags endpoint.
+ url = host.rstrip("/") + "/v1/models"
+ try:
+ data = _http_json("GET", url, token=api_key, timeout=8)
+ rows = _extract_model_rows(data)
+ for item in rows:
+ if not isinstance(item, dict):
+ continue
+ model_id = _model_id_from_row(item)
+ if model_id:
+ models.append(
+ {
+ "id": model_id,
+ "name": model_id,
+ "api_type": "ollama",
+ "base_model": model_id,
+ "variant_tag": "",
+ "raw_model_key": model_id,
+ }
+ )
+ except Exception:
+ url = host.rstrip("/") + "/api/tags"
+ data = _http_json("GET", url, token=api_key, timeout=8)
+ rows = _extract_model_rows(data)
+ for item in rows:
+ if not isinstance(item, dict):
+ continue
+ model_id = _model_id_from_row(item)
+ if model_id:
+ models.append(
+ {
+ "id": model_id,
+ "name": model_id,
+ "api_type": "ollama",
+ "base_model": model_id,
+ "variant_tag": "",
+ "raw_model_key": model_id,
+ }
+ )
+ else:
+ if style == "openai":
+ suffix = "/v1/models"
+ else:
+ suffix = models_path or "/v1/models"
+ if not suffix.startswith("/"):
+ suffix = "/" + suffix
+ url = host.rstrip("/") + suffix
+ data = _http_json("GET", url, token=api_key, timeout=8)
+ rows = _extract_model_rows(data)
+ for item in rows:
+ if not isinstance(item, dict):
+ continue
+ model_id = _model_id_from_row(item)
+ if model_id:
+ models.append(
+ {
+ "id": model_id,
+ "name": model_id,
+ "api_type": style or "custom",
+ "base_model": model_id,
+ "variant_tag": "",
+ "raw_model_key": model_id,
+ }
+ )
+
+ seen: set[str] = set()
+ unique_models: list[dict] = []
+ for model in models:
+ model_id = str(model.get("id") or "")
+ if not model_id or model_id in seen:
+ continue
+ seen.add(model_id)
+ unique_models.append(model)
+ return {"models": unique_models, "url": url}
+ except HTTPException:
+ raise
+ except urlerror.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise HTTPException(status_code=exc.code, detail=detail or f"HTTP {exc.code}")
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"Model query failed: {exc}")
+
+
+@app.post("/api/models/load")
+async def load_model(request: Request):
+ _require_user(request)
+ body = (
+ await request.json()
+ if request.headers.get("content-type", "").startswith("application/json")
+ else {}
+ )
+ host = str((body or {}).get("host") or "").strip()
+ api_key = str((body or {}).get("api_key") or "").strip() or None
+ style = str((body or {}).get("style") or "custom").strip().lower()
+ model = str((body or {}).get("model") or "").strip()
+ context_length_raw = (body or {}).get("context_length")
+
+ if not host:
+ raise HTTPException(status_code=400, detail="Host is required")
+ if not model:
+ raise HTTPException(status_code=400, detail="Model is required")
+ if style != "lm-studio":
+ raise HTTPException(
+ status_code=400,
+ detail="Load model is currently supported for LM Studio profiles only",
+ )
+
+ native = _lmstudio_native_base(host)
+ if not native:
+ raise HTTPException(status_code=400, detail="Invalid LM Studio host URL")
+
+ payload: dict[str, Any] = {"model": model, "echo_load_config": True}
+ if context_length_raw not in (None, ""):
+ try:
+ context_length = int(context_length_raw)
+ if context_length > 0:
+ payload["context_length"] = context_length
+ except (TypeError, ValueError):
+ raise HTTPException(
+ status_code=400, detail="context_length must be a positive integer"
+ )
+
+ try:
+ data = _http_json(
+ "POST",
+ f"{native}/models/load",
+ payload=payload,
+ token=api_key,
+ timeout=30,
+ )
+ return {"ok": True, "result": data}
+ except urlerror.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise HTTPException(status_code=exc.code, detail=detail or f"HTTP {exc.code}")
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"Load model failed: {exc}")
+
+
+@app.post("/api/models/eject")
+async def eject_model(request: Request):
+ _require_user(request)
+ body = (
+ await request.json()
+ if request.headers.get("content-type", "").startswith("application/json")
+ else {}
+ )
+ requested_model = str((body or {}).get("model") or "").strip()
+
+ with registry.SessionLocal() as session:
+ connection = _effective_llm_connection(session)
+
+ base_url = str(connection.get("base_url") or "")
+ api_type = str(connection.get("api_type") or "").lower()
+ api_key = str(connection.get("api_key") or "")
+
+ native_base = _lmstudio_native_base(base_url)
+ if not native_base:
+ raise HTTPException(status_code=400, detail="LLM base_url is not configured")
+ if api_type not in {"openai", "lmstudio", "local"} and "1234" not in base_url:
+ raise HTTPException(
+ status_code=400, detail="Eject is only supported for LM Studio connections"
+ )
+
+ try:
+ listing = _http_json("GET", f"{native_base}/models", token=api_key)
+ models = listing.get("data") if isinstance(listing, dict) else []
+ if not isinstance(models, list):
+ models = []
+
+ target_instance_id = requested_model
+ if not target_instance_id:
+ loaded = [
+ m for m in models if isinstance(m, dict) and m.get("state") == "loaded"
+ ]
+ if len(loaded) == 1:
+ target_instance_id = str(
+ loaded[0].get("id") or loaded[0].get("instance_id") or ""
+ )
+ elif len(loaded) > 1:
+ raise HTTPException(
+ status_code=409,
+ detail="Multiple models are loaded. Specify model id.",
+ )
+
+ if not target_instance_id:
+ raise HTTPException(
+ status_code=404, detail="No loaded model found to eject"
+ )
+
+ instance_id = target_instance_id
+ exact = next(
+ (
+ m
+ for m in models
+ if isinstance(m, dict) and str(m.get("id") or "") == target_instance_id
+ ),
+ None,
+ )
+ if exact:
+ instance_id = str(
+ exact.get("instance_id") or exact.get("id") or target_instance_id
+ )
+
+ unloaded = _http_json(
+ "POST",
+ f"{native_base}/models/unload",
+ payload={"instance_id": instance_id},
+ token=api_key,
+ )
+ return {
+ "ok": True,
+ "requested_model": requested_model or instance_id,
+ "instance_id": unloaded.get("instance_id", instance_id)
+ if isinstance(unloaded, dict)
+ else instance_id,
+ }
+ except HTTPException:
+ raise
+ except urlerror.HTTPError as exc:
+ detail_raw = exc.read().decode("utf-8", errors="ignore")
+ # Idempotent behavior: unloading an already-unloaded model should not fail UX.
+ try:
+ parsed = json.loads(detail_raw) if detail_raw else {}
+ err = parsed.get("error") if isinstance(parsed, dict) else None
+ err_type = str((err or {}).get("type") or "")
+ if err_type == "model_not_found":
+ return {
+ "ok": True,
+ "requested_model": requested_model or "",
+ "instance_id": requested_model or "",
+ "already_unloaded": True,
+ }
+ except Exception:
+ pass
+ raise HTTPException(
+ status_code=exc.code, detail=detail_raw or "LM Studio eject failed"
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"LM Studio eject failed: {exc}")
+
+
+@app.post("/api/connection/verify")
+@app.post("/connection/verify")
+async def verify_connection(request: Request):
+ _require_user(request)
+ body = (
+ await request.json()
+ if request.headers.get("content-type", "").startswith("application/json")
+ else {}
+ )
+ host = str((body or {}).get("host") or "").strip()
+ api_key = str((body or {}).get("api_key") or "").strip() or None
+ style = str((body or {}).get("style") or "custom").strip().lower()
+ models_path = str((body or {}).get("models_path") or "").strip()
+
+ if not host:
+ raise HTTPException(status_code=400, detail="Host is required")
+
+ # Build URL from host + style/path
+ if style == "lm-studio":
+ native = _lmstudio_native_base(host)
+ if not native:
+ raise HTTPException(status_code=400, detail="Invalid LM Studio host URL")
+ url = f"{native}/models"
+ elif style in {"openai", "ollama"}:
+ suffix = "/v1/models"
+ url = host.rstrip("/") + suffix
+ else:
+ suffix = models_path or "/v1/models"
+ if not suffix.startswith("/"):
+ suffix = "/" + suffix
+ url = host.rstrip("/") + suffix
+
+ try:
+ data = _http_json("GET", url, token=api_key, timeout=8)
+ count = len(_extract_model_rows(data))
+ return {"ok": True, "url": url, "models_count": count}
+ except urlerror.HTTPError as exc:
+ detail = exc.read().decode("utf-8", errors="ignore")
+ raise HTTPException(status_code=exc.code, detail=detail or f"HTTP {exc.code}")
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"Connection verify failed: {exc}")
+
+
+@app.get("/api/tools")
+async def list_tools(request: Request):
+ _require_user(request)
+ with registry.SessionLocal() as session:
+ global_tools = _get_app_setting(session, "tools", {})
+ disabled = (
+ set(global_tools.get("disabled", []))
+ if isinstance(global_tools, dict)
+ else set()
+ )
+ return {
+ "tools": [
+ {**tool, "enabled": tool["name"] not in disabled}
+ for tool in AVAILABLE_TOOLS
+ ]
+ }
+
+
+@app.get("/api/skills")
+async def list_skills(
+ request: Request,
+ conversation_id: Optional[str] = None,
+ prompt: Optional[str] = None,
+):
+ user = _require_user(request)
+ workspace = None
+ include_vendor = True
+ disabled_skills: set[str] = set()
+ if conversation_id:
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ settings = conversation.settings or {}
+ include_vendor = bool(settings.get("enable_vendor_skills", True))
+ disabled_skills = {
+ str(name)
+ for name in (settings.get("disabled_skills") or [])
+ if str(name).strip()
+ }
+ workspace = Path(WORKSPACE_ROOT) / "conversations" / conversation_id
+ skills = (
+ select_skills(
+ prompt or "",
+ workspace,
+ include_vendor=include_vendor,
+ disabled_skills=disabled_skills,
+ )
+ if prompt
+ else load_skills(
+ workspace,
+ include_vendor=include_vendor,
+ disabled_skills=set(),
+ )
+ )
+ output = []
+ for skill in skills:
+ item = skill.summary()
+ item["enabled"] = skill.name not in disabled_skills
+ output.append(item)
+ return {"skills": output}
+
+
+async def _task_stream_progress(task: TaskORM) -> list[dict]:
+ redis = aioredis.from_url(REDIS_URL, decode_responses=True)
+ stream_key = f"task:{task.task_id}:stream"
+ progress_events: list[dict] = []
+ try:
+ entries = await redis.xrange(stream_key, min="-", max="+")
+ finally:
+ await redis.aclose()
+
+ prompt = (task.input or {}).get("prompt", "")
+ has_emitted_complete = False
+ for msg_id, fields in entries:
+ event_type = fields.get("type", "")
+ try:
+ data = json.loads(fields.get("data", "{}"))
+ except Exception:
+ data = {}
+
+ progress_list = _agent_event_to_progress({"type": event_type, "data": data})
+ for progress_index, progress in enumerate(progress_list):
+ if progress.get("name") == "agent:lifecycle:start":
+ content = dict(progress.get("content") or {})
+ content.setdefault("request", prompt)
+ content.setdefault("task_id", str(task.task_id))
+ content.setdefault("conversation_id", _conversation_id_for(task))
+ progress["content"] = content
+ else:
+ content = dict(progress.get("content") or {})
+ content.setdefault("task_id", str(task.task_id))
+ content.setdefault("conversation_id", _conversation_id_for(task))
+ progress["content"] = content
+
+ # Deduplicate agent:lifecycle:complete events
+ is_complete_event = progress.get("name") == "agent:lifecycle:complete"
+ if is_complete_event and has_emitted_complete:
+ continue
+ if is_complete_event:
+ has_emitted_complete = True
+
+ progress_events.append(
+ {
+ **progress,
+ "id": f"{task.task_id}:{msg_id}:{progress_index}:{progress.get('name')}",
+ "task_id": str(task.task_id),
+ "created_at": task.created_at.isoformat()
+ if task.created_at
+ else None,
+ }
+ )
+
+ if not progress_events and task.status in TERMINAL_STATUSES:
+ progress_events = [
+ {
+ "id": f"{task.task_id}:synthetic:start",
+ "type": "progress",
+ "name": "agent:lifecycle:start",
+ "task_id": str(task.task_id),
+ "created_at": task.created_at.isoformat() if task.created_at else None,
+ "content": {
+ "request": prompt,
+ "task_id": str(task.task_id),
+ "conversation_id": _conversation_id_for(task),
+ },
+ },
+ {
+ "id": f"{task.task_id}:synthetic:complete",
+ "type": "progress",
+ "name": "agent:lifecycle:complete",
+ "task_id": str(task.task_id),
+ "created_at": task.created_at.isoformat() if task.created_at else None,
+ "content": {
+ "message": "Task already completed",
+ "task_id": str(task.task_id),
+ "conversation_id": _conversation_id_for(task),
+ },
+ },
+ ]
+
+ return progress_events
+
+
+def _event_row_to_progress(
+ row: ConversationEventORM, task: Optional[TaskORM] = None
+) -> list[dict]:
+ task_id = str(row.task_id) if row.task_id else ""
+ prompt = (task.input or {}).get("prompt", "") if task is not None else ""
+ progress_items = _agent_event_to_progress(
+ {"type": row.event_type, "data": row.payload or {}}
+ )
+ output: list[dict] = []
+ for index, progress in enumerate(progress_items):
+ content = dict(progress.get("content") or {})
+ if task_id:
+ content.setdefault("task_id", task_id)
+ content.setdefault("conversation_id", str(row.conversation_id))
+ if progress.get("name") == "agent:lifecycle:start":
+ content.setdefault("request", prompt)
+ output.append(
+ {
+ **progress,
+ "id": f"event:{row.event_id}:{index}:{progress.get('name')}",
+ "event_id": row.event_id,
+ "task_id": task_id or None,
+ "created_at": row.created_at.isoformat() if row.created_at else None,
+ "content": content,
+ }
+ )
+ return output
+
+
+def _truncate_text(value: str, limit: int = 8000) -> str:
+ if len(value) <= limit:
+ return value
+ return value[:limit] + f"\n...[truncated {len(value) - limit} chars]"
+
+
+def _compact_history_value(value, *, text_limit: int = 8000):
+ """Compact oversized event payloads for history endpoint stability."""
+ if isinstance(value, str):
+ return _truncate_text(value, text_limit)
+ if isinstance(value, list):
+ return [
+ _compact_history_value(item, text_limit=text_limit) for item in value[:200]
+ ]
+ if isinstance(value, dict):
+ compacted = {}
+ for key, item in value.items():
+ key_s = str(key)
+ # Avoid shipping giant inline screenshots in history payloads.
+ if key_s in {"screenshot", "base64_image", "image"} and isinstance(
+ item, str
+ ):
+ compacted[key_s] = f"[omitted base64 payload: {len(item)} chars]"
+ continue
+ compacted[key_s] = _compact_history_value(item, text_limit=text_limit)
+ return compacted
+ return value
+
+
+def _compact_history_event(event: dict) -> dict:
+ compacted = dict(event)
+ compacted["content"] = _compact_history_value(event.get("content") or {})
+ return compacted
+
+
+@app.get("/api/admin/settings")
+async def get_admin_settings(request: Request):
+ _require_admin(request)
+ with registry.SessionLocal() as session:
+ llm_connection = _get_app_setting(session, "llm_connection", {})
+ return {
+ "llm_connection": llm_connection or _default_llm_connection(),
+ "llm_connection_override": llm_connection,
+ "tools": _get_app_setting(session, "tools", {"disabled": []}),
+ "config_defaults": _loaded_config_defaults(),
+ "config_overrides": _get_app_setting(session, "config_overrides", {}),
+ "available_tools": AVAILABLE_TOOLS,
+ "models": (await list_models(request))["models"],
+ }
+
+
+@app.put("/api/admin/settings")
+async def update_admin_settings(request: Request):
+ _require_admin(request)
+ body = await request.json()
+ with registry.SessionLocal() as session:
+ if "llm_connection" in body:
+ allowed = {
+ key: body["llm_connection"].get(key)
+ for key in [
+ "model",
+ "base_url",
+ "api_key",
+ "api_type",
+ "max_tokens",
+ "temperature",
+ "fallback_chain",
+ ]
+ if body["llm_connection"].get(key) not in (None, "")
+ }
+ if "fallback_chain" in allowed and not isinstance(
+ allowed["fallback_chain"], list
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="llm_connection.fallback_chain must be a list of connection objects",
+ )
+ _set_app_setting(session, "llm_connection", allowed)
+ if "tools" in body:
+ disabled = [
+ str(name)
+ for name in body["tools"].get("disabled", [])
+ if str(name) not in {"terminate"}
+ ]
+ _set_app_setting(session, "tools", {"disabled": disabled})
+ if "config_overrides" in body:
+ overrides = body["config_overrides"]
+ if not isinstance(overrides, dict):
+ raise HTTPException(
+ status_code=400, detail="config_overrides must be an object"
+ )
+ _set_app_setting(session, "config_overrides", overrides)
+ session.commit()
+ llm_connection = _get_app_setting(session, "llm_connection", {})
+ return {
+ "llm_connection": llm_connection or _default_llm_connection(),
+ "llm_connection_override": llm_connection,
+ "tools": _get_app_setting(session, "tools", {"disabled": []}),
+ "config_defaults": _loaded_config_defaults(),
+ "config_overrides": _get_app_setting(session, "config_overrides", {}),
+ "available_tools": AVAILABLE_TOOLS,
+ }
+
+
+@app.get("/api/conversations")
+async def list_conversations(request: Request):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ from sqlalchemy import desc
+
+ _prune_orphan_conversation_sandboxes(session)
+
+ conversations = (
+ session.query(ConversationORM)
+ .filter(ConversationORM.user_id == user.user_id)
+ .order_by(
+ desc(ConversationORM.updated_at), desc(ConversationORM.created_at)
+ )
+ .all()
+ )
+ if not conversations:
+ conversations = [_ensure_default_conversation(session, user.user_id)]
+ session.commit()
+ return {
+ "conversations": [
+ _conversation_to_dict(session, conversation)
+ for conversation in conversations
+ ]
+ }
+
+
+@app.post("/api/conversations")
+async def create_conversation(request: Request):
+ user = _require_user(request)
+ title = "New conversation"
+ model = None
+ try:
+ body = await request.json()
+ title = str(body.get("title") or title).strip() or title
+ model = str(body.get("model") or "").strip() or None
+ except Exception:
+ pass
+ with registry.SessionLocal() as session:
+ conversation = ConversationORM(
+ user_id=user.user_id, title=title[:120], model=model
+ )
+ session.add(conversation)
+ session.commit()
+ session.refresh(conversation)
+ return _conversation_to_dict(session, conversation)
+
+
+@app.get("/api/conversations/{conversation_id}")
+async def get_conversation(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ return _conversation_to_dict(session, conversation)
+
+
+@app.get("/api/conversations/{conversation_id}/tasks")
+async def get_conversation_tasks(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ tasks = _conversation_tasks(
+ session, str(conversation.conversation_id), ascending=True
+ )
+ return {"tasks": [_task_to_dict(task) for task in tasks]}
+
+
+@app.get("/api/conversations/{conversation_id}/events/history")
+async def get_conversation_event_history(
+ request: Request,
+ conversation_id: str,
+ limit: int = 160,
+ before_event_id: Optional[int] = None,
+ kind: Optional[str] = None,
+):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ tasks = _conversation_tasks(
+ session, str(conversation.conversation_id), ascending=True
+ )
+ # Recover stale non-terminal tasks during history load so UI does not
+ # keep trying to tail a dead SSE stream forever.
+ now = _now()
+ stale_seconds = 300
+ for task in tasks:
+ # Only recover stale CREATED tasks here.
+ # RUNNING tasks may legitimately execute for a long time.
+ if str(task.status) != "CREATED":
+ continue
+ created_at = task.created_at or now
+ age = (now - created_at).total_seconds()
+ if age <= stale_seconds:
+ continue
+ task.status = "FAILED"
+ task.result = {
+ "error": "Stale task recovered while loading conversation history."
+ }
+ session.commit()
+ tasks = _conversation_tasks(
+ session, str(conversation.conversation_id), ascending=True
+ )
+ conversation_payload = _conversation_to_dict(session, conversation)
+ task_payloads = [_task_to_dict(task) for task in tasks]
+ task_by_id = {str(task.task_id): task for task in tasks}
+
+ query = session.query(ConversationEventORM).filter(
+ ConversationEventORM.conversation_id == conversation.conversation_id
+ )
+ if before_event_id is not None:
+ query = query.filter(ConversationEventORM.event_id < before_event_id)
+ if kind:
+ query = query.filter(ConversationEventORM.event_type == kind)
+ page_size = max(1, min(limit, 2000))
+ # Load the newest page first for responsive conversation open.
+ rows = (
+ query.order_by(ConversationEventORM.event_id.desc()).limit(page_size).all()
+ )
+ rows = list(reversed(rows))
+
+ events: list[dict] = []
+ if rows:
+ has_emitted_complete_by_task: set[str] = set()
+ for row in rows:
+ task_key = str(row.task_id) if row.task_id else ""
+ for event in _event_row_to_progress(row, task_by_id.get(task_key)):
+ is_complete = event.get("name") == "agent:lifecycle:complete"
+ if is_complete and task_key in has_emitted_complete_by_task:
+ continue
+ if is_complete and task_key:
+ has_emitted_complete_by_task.add(task_key)
+ events.append(_compact_history_event(event))
+ else:
+ for task in tasks:
+ events.extend(
+ [
+ _compact_history_event(item)
+ for item in await _task_stream_progress(task)
+ ]
+ )
+
+ next_before_event_id = rows[0].event_id if rows else None
+ return {
+ "conversation": conversation_payload,
+ "tasks": task_payloads,
+ "events": events,
+ "pagination": {
+ "limit": page_size,
+ "next_before_event_id": next_before_event_id,
+ "has_more": len(rows) == page_size and next_before_event_id is not None,
+ },
+ }
+
+
+@app.post("/api/conversations/{conversation_id}/obsidian/import")
+async def import_obsidian_context(request: Request, conversation_id: str):
+ """Import Obsidian notes and build a wikilink graph for this conversation."""
+ user = _require_user(request)
+ body = await request.json()
+ notes = body.get("notes") or []
+ if not isinstance(notes, list) or not notes:
+ raise HTTPException(status_code=400, detail="notes[] is required")
+
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ cid = conversation.conversation_id
+ existing = (
+ session.query(ObsidianNoteORM)
+ .filter(ObsidianNoteORM.conversation_id == cid)
+ .all()
+ )
+ by_path = {note.path: note for note in existing}
+ by_title = {note.title: note for note in existing}
+
+ upserted: list[ObsidianNoteORM] = []
+ for raw in notes:
+ if not isinstance(raw, dict):
+ continue
+ path = str(raw.get("path") or raw.get("title") or "").strip()
+ title = str(raw.get("title") or Path(path).stem or "").strip()
+ content = str(raw.get("content") or "")
+ tags = raw.get("tags") if isinstance(raw.get("tags"), list) else []
+ meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {}
+ if not path or not title:
+ continue
+ note = by_path.get(path)
+ if note is None:
+ note = ObsidianNoteORM(
+ conversation_id=cid,
+ path=path,
+ title=title,
+ content=content,
+ tags=tags,
+ meta=meta,
+ )
+ session.add(note)
+ else:
+ note.title = title
+ note.content = content
+ note.tags = tags
+ note.meta = meta
+ upserted.append(note)
+ by_path[path] = note
+ by_title[title] = note
+
+ session.flush()
+
+ session.query(ObsidianEdgeORM).filter(
+ ObsidianEdgeORM.conversation_id == cid
+ ).delete()
+
+ edges_created = 0
+ for note in upserted:
+ for target_name in _extract_wikilinks(note.content):
+ target = by_title.get(target_name) or by_path.get(target_name)
+ if target is None:
+ continue
+ session.add(
+ ObsidianEdgeORM(
+ conversation_id=cid,
+ source_note_id=note.note_id,
+ target_note_id=target.note_id,
+ relation="wikilink",
+ )
+ )
+ edges_created += 1
+
+ conversation.updated_at = _now()
+ session.commit()
+
+ payload = _obsidian_graph_payload(session, conversation)
+ return {
+ "conversation_id": conversation_id,
+ "imported_notes": len(upserted),
+ "edges_created": edges_created,
+ "graph": payload,
+ }
+
+
+@app.get("/api/conversations/{conversation_id}/obsidian/graph")
+async def get_obsidian_graph(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ return {
+ "conversation_id": conversation_id,
+ **_obsidian_graph_payload(session, conversation),
+ }
+
+
+@app.get("/api/conversations/{conversation_id}/obsidian/context")
+async def get_obsidian_context(request: Request, conversation_id: str, limit: int = 8):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ notes = (
+ session.query(ObsidianNoteORM)
+ .filter(ObsidianNoteORM.conversation_id == conversation.conversation_id)
+ .order_by(ObsidianNoteORM.updated_at.desc())
+ .limit(max(1, min(limit, 30)))
+ .all()
+ )
+ items = []
+ for note in notes:
+ content = (note.content or "").strip()
+ if len(content) > 900:
+ content = content[:900] + "..."
+ items.append(
+ {
+ "id": str(note.note_id),
+ "path": note.path,
+ "title": note.title,
+ "tags": note.tags or [],
+ "content": content,
+ }
+ )
+ return {"conversation_id": conversation_id, "notes": items, "count": len(items)}
+
+
+@app.get("/api/conversations/{conversation_id}/events/count")
+async def get_conversation_event_count(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ counts = (
+ session.query(
+ ConversationEventORM.event_type,
+ func.count(ConversationEventORM.event_id),
+ )
+ .filter(
+ ConversationEventORM.conversation_id == conversation.conversation_id
+ )
+ .group_by(ConversationEventORM.event_type)
+ .all()
+ )
+ return {
+ "conversation_id": conversation_id,
+ "total": sum(int(count) for _, count in counts),
+ "by_type": {event_type: int(count) for event_type, count in counts},
+ }
+
+
+@app.get("/api/conversations/{conversation_id}/runtime")
+async def get_conversation_runtime(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+
+ sandbox = _conversation_sandbox(conversation_id)
+ try:
+ sandbox_status = await sandbox.status()
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc))
+
+ status_value = str((sandbox_status or {}).get("status") or "").lower()
+ # Do not hard-fail runtime view when sandbox is paused/stopped/missing.
+ # In these states process/container inspection may fail by design.
+ if status_value in {"paused", "exited", "dead", "missing", ""}:
+ return {
+ "conversation_id": conversation_id,
+ "sandbox": sandbox_status,
+ "status": "paused" if status_value == "paused" else "idle",
+ "processes": [],
+ "containers": [],
+ "urls": [],
+ "hidden_system_containers": 0,
+ "running_count": 0,
+ }
+
+ try:
+ processes, containers, urls = await asyncio.gather(
+ sandbox.list_processes(),
+ sandbox.list_docker_containers(),
+ sandbox.list_exposed_urls(),
+ )
+ except Exception:
+ # Runtime panel should stay available even when deep inspection fails.
+ processes, containers, urls = [], [], []
+
+ killable_processes = [
+ item
+ for item in processes
+ if not item.get("protected") and not item.get("zombie")
+ ]
+ visible_containers = [item for item in containers if not item.get("protected")]
+ killable_containers = visible_containers
+ return {
+ "conversation_id": conversation_id,
+ "sandbox": sandbox_status,
+ "status": "running" if killable_processes or killable_containers else "idle",
+ "processes": processes,
+ "containers": visible_containers,
+ "urls": urls,
+ "hidden_system_containers": len(containers) - len(visible_containers),
+ "running_count": len(killable_processes) + len(killable_containers),
+ "agentmemory": _agentmemory_health(conversation_id),
+ }
+
+
+@app.get("/api/conversations/{conversation_id}/state")
+async def get_conversation_state(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ conversation_payload = _conversation_to_dict(session, conversation)
+ sandbox_status = await _conversation_sandbox(conversation_id).status()
+ return {
+ "conversation_id": conversation_id,
+ "state": conversation_payload.get("state", "idle"),
+ "latest_status": conversation_payload.get("latest_status"),
+ "sandbox": sandbox_status,
+ }
+
+
+@app.get("/api/conversations/{conversation_id}/integrations/health")
+async def get_conversation_integrations_health(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+ return {
+ "conversation_id": conversation_id,
+ "agentmemory": _agentmemory_health(conversation_id),
+ "obsidian": _obsidian_health(session, conversation_id),
+ "llm_connection": _llm_connection_health(session),
+ }
+
+
+@app.post("/api/conversations/{conversation_id}/sandbox/start")
+async def start_conversation_sandbox(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+ sandbox = await _conversation_sandbox(conversation_id).ensure()
+ return {"conversation_id": conversation_id, "sandbox": await sandbox.status()}
+
+
+@app.post("/api/conversations/{conversation_id}/sandbox/pause")
+async def pause_conversation_sandbox(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+ sandbox = _conversation_sandbox(conversation_id)
+ await sandbox.pause()
+ return {"conversation_id": conversation_id, "sandbox": await sandbox.status()}
+
+
+@app.post("/api/conversations/{conversation_id}/sandbox/resume")
+async def resume_conversation_sandbox(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+ sandbox = _conversation_sandbox(conversation_id)
+ await sandbox.resume()
+ return {"conversation_id": conversation_id, "sandbox": await sandbox.status()}
+
+
+@app.delete("/api/conversations/{conversation_id}/sandbox")
+async def delete_conversation_sandbox(request: Request, conversation_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+ await _conversation_sandbox(conversation_id).delete()
+ return {"conversation_id": conversation_id, "deleted": True}
+
+
+@app.post("/api/conversations/{conversation_id}/runtime/processes/{pid}/kill")
+async def kill_conversation_process(request: Request, conversation_id: str, pid: int):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+
+ body = {}
+ try:
+ body = await request.json()
+ except Exception:
+ pass
+ signal = str(body.get("signal") or "TERM")
+ try:
+ await _conversation_sandbox(conversation_id).kill_process(pid, signal=signal)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc))
+ return {"conversation_id": conversation_id, "pid": pid, "killed": True}
+
+
+@app.post("/api/conversations/{conversation_id}/runtime/containers/{container_id}/stop")
+async def stop_conversation_container(
+ request: Request, conversation_id: str, container_id: str
+):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ _require_conversation(session, user.user_id, conversation_id)
+
+ try:
+ await _conversation_sandbox(conversation_id).stop_docker_container(container_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc))
+ return {
+ "conversation_id": conversation_id,
+ "container_id": container_id,
+ "stopped": True,
+ }
+
+
+@app.put("/api/conversations/{conversation_id}/settings")
+async def update_conversation_settings(request: Request, conversation_id: str):
+ user = _require_user(request)
+ body = await request.json()
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ settings = dict(conversation.settings or {})
+ if "model" in body:
+ model = str(body.get("model") or "").strip()
+ conversation.model = model or None
+ if "disabled_tools" in body:
+ settings["disabled_tools"] = [
+ str(name)
+ for name in body.get("disabled_tools", [])
+ if str(name) != "terminate"
+ ]
+ if "requested_context_window" in body:
+ raw_window = body.get("requested_context_window")
+ if raw_window in (None, "", 0):
+ settings.pop("requested_context_window", None)
+ else:
+ try:
+ value = int(raw_window)
+ except (TypeError, ValueError):
+ raise HTTPException(
+ status_code=400,
+ detail="requested_context_window must be a positive integer",
+ )
+ if value <= 0:
+ raise HTTPException(
+ status_code=400,
+ detail="requested_context_window must be a positive integer",
+ )
+ settings["requested_context_window"] = value
+ if "auto_context_compress" in body:
+ settings["auto_context_compress"] = bool(body.get("auto_context_compress"))
+ if "disabled_skills" in body:
+ settings["disabled_skills"] = [
+ str(name).strip()
+ for name in body.get("disabled_skills", [])
+ if str(name).strip()
+ ]
+ if "enable_vendor_skills" in body:
+ settings["enable_vendor_skills"] = bool(body.get("enable_vendor_skills"))
+ if "pinned_skills" in body:
+ settings["pinned_skills"] = [
+ str(name).strip()
+ for name in body.get("pinned_skills", [])
+ if str(name).strip()
+ ]
+ if "identity_notes" in body:
+ settings["identity_notes"] = str(body.get("identity_notes") or "").strip()
+ if "auto_skill_curator" in body:
+ settings["auto_skill_curator"] = bool(body.get("auto_skill_curator"))
+ conversation.settings = settings
+ conversation.updated_at = _now()
+ session.commit()
+ session.refresh(conversation)
+ return _conversation_to_dict(session, conversation)
+
+
+@app.delete("/api/conversations/{conversation_id}")
+async def delete_conversation(request: Request, conversation_id: str):
+ user = _require_user(request)
+ import redis as redis_lib
+
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ tasks = (
+ session.query(TaskORM)
+ .filter(
+ TaskORM.input["conversation_id"].astext
+ == str(conversation.conversation_id)
+ )
+ .all()
+ )
+ task_ids = [str(task.task_id) for task in tasks]
+ session.query(ConversationEventORM).filter(
+ ConversationEventORM.conversation_id == conversation.conversation_id
+ ).delete(synchronize_session=False)
+ # Remove Obsidian graph rows before deleting the conversation row
+ # to satisfy foreign key constraints.
+ session.query(ObsidianEdgeORM).filter(
+ ObsidianEdgeORM.conversation_id == conversation.conversation_id
+ ).delete(synchronize_session=False)
+ session.query(ObsidianNoteORM).filter(
+ ObsidianNoteORM.conversation_id == conversation.conversation_id
+ ).delete(synchronize_session=False)
+ for task in tasks:
+ session.delete(task)
+ session.delete(conversation)
+ session.commit()
+
+ try:
+ r = redis_lib.from_url(REDIS_URL, decode_responses=True)
+ for tid in task_ids:
+ r.delete(f"task:{tid}:stream")
+ r.delete(f"task:{tid}:inbox")
+ r.close()
+ except Exception:
+ pass
+
+ workspace_path = os.path.join(WORKSPACE_ROOT, "conversations", conversation_id)
+ shutil.rmtree(workspace_path, ignore_errors=True)
+
+ try:
+ import docker
+
+ client = docker.from_env()
+ container = client.containers.get(f"openmanus_sandbox_{conversation_id}")
+ container.remove(force=True)
+ except Exception:
+ pass
+
+ return {"id": conversation_id, "deleted": True}
+
+
+# ---------------------------------------------------------------------------
+# Task list (sidebar history)
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/tasks")
+async def list_tasks(request: Request, page: int = 1, pageSize: int = 30):
+ """List tasks from the database for the sidebar."""
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ from sqlalchemy import desc
+
+ conversation_ids = [
+ str(row[0])
+ for row in session.query(ConversationORM.conversation_id)
+ .filter(ConversationORM.user_id == user.user_id)
+ .all()
+ ]
+ offset = (page - 1) * pageSize
+ orms = (
+ session.query(TaskORM)
+ .filter(TaskORM.input["conversation_id"].astext.in_(conversation_ids))
+ .order_by(desc(TaskORM.created_at))
+ .offset(offset)
+ .limit(pageSize)
+ .all()
+ )
+
+ # The hook does: res.data?.tasks so we must nest it under "tasks"
+ tasks = [
+ {
+ "id": str(orm.task_id),
+ "task_id": str(orm.task_id),
+ "status": orm.status,
+ "conversation_id": _conversation_id_for(orm),
+ "created_at": orm.created_at.isoformat() if orm.created_at else None,
+ # The sidebar uses 'request' as the display label
+ "request": (orm.input or {}).get("prompt", "Untitled task"),
+ }
+ for orm in orms
+ ]
+
+ return {"tasks": tasks, "total": len(tasks)}
+
+
+# ---------------------------------------------------------------------------
+# SSE event stream
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/tasks/{task_id}/events")
+async def task_events(request: Request, task_id: str):
+ """SSE endpoint: read from Redis Stream so events are never missed."""
+ user = _require_user(request)
+
+ # Check current task status from DB before opening the stream
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None or not _task_belongs_to_user(session, orm, user.user_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ task_is_done = orm is not None and orm.status in (
+ "COMPLETED",
+ "FAILED",
+ "INTERRUPTED",
+ )
+ task_prompt = (orm.input or {}).get("prompt", "")
+ task_conversation_id = _conversation_id_for(orm)
+
+ async def event_generator():
+ redis = aioredis.from_url(REDIS_URL, decode_responses=True)
+ stream_key = f"task:{task_id}:stream"
+ last_id = "0" # start from the very beginning of the stream
+ empty_polls = 0
+ has_emitted_complete = False
+
+ try:
+ while True:
+ if await request.is_disconnected():
+ break
+
+ # XREAD blocks up to 1 second waiting for new entries
+ results = await redis.xread({stream_key: last_id}, count=10, block=1000)
+
+ if not results:
+ if task_is_done:
+ # Task is completed in DB but no stream data exists
+ # (task ran before Redis Streams were added, or stream expired).
+ # Send synthetic lifecycle events so the UI can close cleanly.
+ empty_polls += 1
+ if empty_polls >= 2: # wait 2 seconds then synthesize
+ yield {
+ "data": json.dumps(
+ {
+ "type": "progress",
+ "name": "agent:lifecycle:start",
+ "content": {},
+ }
+ )
+ }
+ yield {
+ "data": json.dumps(
+ {
+ "type": "progress",
+ "name": "agent:lifecycle:complete",
+ "content": {
+ "message": "Task already completed"
+ },
+ }
+ )
+ }
+ # Grace period: let browser receive and process before we close
+ await asyncio.sleep(3)
+ return
+ # Active task with no events yet — send keep-alive ping
+ yield {"data": json.dumps({"type": "ping"})}
+ continue
+
+ # Reset empty poll counter whenever we get events
+ empty_polls = 0
+
+ done = False
+ for _stream, messages in results:
+ for msg_id, fields in messages:
+ last_id = msg_id
+ event_type = fields.get("type", "")
+ try:
+ data = json.loads(fields.get("data", "{}"))
+ except Exception:
+ data = {}
+
+ progress_list = _agent_event_to_progress(
+ {"type": event_type, "data": data}
+ )
+ for progress_index, progress in enumerate(progress_list):
+ content = dict(progress.get("content") or {})
+ content.setdefault("task_id", task_id)
+ content.setdefault("conversation_id", task_conversation_id)
+ if progress.get("name") == "agent:lifecycle:start":
+ content.setdefault("request", task_prompt)
+ progress["content"] = content
+
+ is_complete = (
+ progress.get("name") == "agent:lifecycle:complete"
+ )
+ if is_complete and has_emitted_complete:
+ continue
+ if is_complete:
+ has_emitted_complete = True
+
+ progress[
+ "id"
+ ] = f"{task_id}:{msg_id}:{progress_index}:{progress.get('name')}"
+ progress["task_id"] = task_id
+ yield {"data": json.dumps(progress)}
+ if progress["name"] in (
+ "agent:lifecycle:complete",
+ "agent:lifecycle:terminated",
+ "agent:lifecycle:step:error",
+ ):
+ done = True
+ break
+ if done:
+ break
+ if done:
+ break
+
+ if done:
+ # Grace period: give the browser time to receive the completion
+ # event and call eventSource.close() itself, BEFORE we close the
+ # TCP connection — this prevents the browser firing onerror and
+ # showing the "connection failed" toast.
+ await asyncio.sleep(3)
+ return
+
+ finally:
+ await redis.aclose()
+
+ return EventSourceResponse(event_generator())
+
+
+# ---------------------------------------------------------------------------
+# Create task — frontend sends multipart/form-data
+# ---------------------------------------------------------------------------
+
+
+@app.post("/api/tasks")
+async def create_task(
+ request: Request,
+ prompt: Optional[str] = Form(None),
+ task_id: Optional[str] = Form(None),
+ conversation_id: Optional[str] = Form(None),
+ model: Optional[str] = Form(None),
+):
+ """Create a new task, or queue a follow-up run on an existing task."""
+ user = _require_user(request)
+
+ if task_id:
+ # Check if this task already exists (follow-up message in a conversation)
+ with registry.SessionLocal() as session:
+ existing_orm = session.get(TaskORM, task_id)
+ if existing_orm is not None and not _task_belongs_to_user(
+ session, existing_orm, user.user_id
+ ):
+ raise HTTPException(status_code=404, detail="Task not found")
+
+ if existing_orm is not None:
+ conversation_id = _conversation_id_for(existing_orm)
+ with registry.SessionLocal() as session:
+ conversation = session.get(
+ ConversationORM, uuid.UUID(str(conversation_id))
+ )
+ conversation_settings = (
+ conversation.settings if conversation is not None else {}
+ )
+ run_model = model or (
+ conversation.model if conversation is not None else None
+ )
+ disabled_tools = list(
+ (conversation_settings or {}).get("disabled_tools", [])
+ )
+ requested_context_window = (conversation_settings or {}).get(
+ "requested_context_window"
+ )
+ auto_context_compress = (conversation_settings or {}).get(
+ "auto_context_compress", True
+ )
+ disabled_skills = list(
+ (conversation_settings or {}).get("disabled_skills", [])
+ )
+ enable_vendor_skills = bool(
+ (conversation_settings or {}).get("enable_vendor_skills", True)
+ )
+ pinned_skills = list(
+ (conversation_settings or {}).get("pinned_skills", [])
+ )
+ identity_notes = str(
+ (conversation_settings or {}).get("identity_notes", "")
+ )
+ if conversation is not None:
+ conversation.updated_at = _now()
+ session.commit()
+ if existing_orm.status in TERMINAL_STATUSES:
+ task = registry.create_task(
+ input=_task_input(
+ prompt,
+ conversation_id,
+ parent_task_id=task_id,
+ model=run_model,
+ disabled_tools=disabled_tools,
+ requested_context_window=requested_context_window,
+ auto_context_compress=auto_context_compress,
+ disabled_skills=disabled_skills,
+ enable_vendor_skills=enable_vendor_skills,
+ pinned_skills=pinned_skills,
+ identity_notes=identity_notes,
+ )
+ )
+ task.status = TaskStatus.CREATED
+ run_task.apply_async(args=[task.id, prompt], task_id=task.id)
+ return {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ "data": {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ },
+ }
+
+ # Recovery path: if a task stayed non-terminal for too long
+ # (typically after worker restart/crash), mark it failed so the
+ # conversation can continue with a fresh run.
+ age_seconds = (_now() - (existing_orm.updated_at or _now())).total_seconds()
+ if existing_orm.status == "CREATED" and age_seconds > 300:
+ with registry.SessionLocal() as session:
+ stale = session.get(TaskORM, existing_orm.task_id)
+ if stale is not None and stale.status == "CREATED":
+ stale.status = "FAILED"
+ stale.result = {
+ "error": "Stale task recovered after worker interruption."
+ }
+ session.commit()
+ # Re-read latest state after recovery mark.
+ with registry.SessionLocal() as session:
+ refreshed = session.get(TaskORM, existing_orm.task_id)
+ if refreshed is not None and refreshed.status in TERMINAL_STATUSES:
+ existing_orm = refreshed
+ task = registry.create_task(
+ input=_task_input(
+ prompt,
+ conversation_id,
+ parent_task_id=task_id,
+ model=run_model,
+ disabled_tools=disabled_tools,
+ requested_context_window=requested_context_window,
+ auto_context_compress=auto_context_compress,
+ disabled_skills=disabled_skills,
+ enable_vendor_skills=enable_vendor_skills,
+ pinned_skills=pinned_skills,
+ identity_notes=identity_notes,
+ )
+ )
+ task.status = TaskStatus.CREATED
+ run_task.apply_async(args=[task.id, prompt], task_id=task.id)
+ return {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ "data": {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ },
+ }
+
+ # Task exists but is NOT in a terminal status.
+ # We must not queue another Celery task for the exact same task ID,
+ # otherwise two agents will run concurrently and exhaust the server.
+ raise HTTPException(
+ status_code=409,
+ detail="Task is currently running. Please wait for it to finish or interrupt it before sending a new prompt.",
+ )
+
+ # New task — create a DB record and queue it. When the caller did not
+ # provide an id, use one generated id for both the first task and the
+ # conversation so the workspace path stays easy to reason about.
+ new_task_id = str(task_id or uuid.uuid4())
+ with registry.SessionLocal() as session:
+ if conversation_id:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ else:
+ conversation = _ensure_default_conversation(session, user.user_id)
+ if prompt and conversation.title == "New conversation":
+ conversation.title = (
+ prompt.strip().splitlines()[0][:80] or conversation.title
+ )
+ if model:
+ conversation.model = model
+ run_model = model or conversation.model
+ disabled_tools = list((conversation.settings or {}).get("disabled_tools", []))
+ requested_context_window = (conversation.settings or {}).get(
+ "requested_context_window"
+ )
+ auto_context_compress = (conversation.settings or {}).get(
+ "auto_context_compress", True
+ )
+ disabled_skills = list((conversation.settings or {}).get("disabled_skills", []))
+ enable_vendor_skills = bool(
+ (conversation.settings or {}).get("enable_vendor_skills", True)
+ )
+ pinned_skills = list((conversation.settings or {}).get("pinned_skills", []))
+ identity_notes = str((conversation.settings or {}).get("identity_notes", ""))
+ conversation.updated_at = _now()
+ session.commit()
+ conversation_id = str(conversation.conversation_id)
+
+ task = registry.create_task(
+ task_id=new_task_id,
+ input=_task_input(
+ prompt,
+ conversation_id,
+ model=run_model,
+ disabled_tools=disabled_tools,
+ requested_context_window=requested_context_window,
+ auto_context_compress=auto_context_compress,
+ disabled_skills=disabled_skills,
+ enable_vendor_skills=enable_vendor_skills,
+ pinned_skills=pinned_skills,
+ identity_notes=identity_notes,
+ ),
+ )
+ task.status = TaskStatus.CREATED
+ run_task.apply_async(args=[task.id, prompt], task_id=task.id)
+
+ return {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ "data": {
+ "task_id": task.id,
+ "status": str(task.status),
+ "conversation_id": conversation_id,
+ },
+ }
+
+
+@app.post("/api/tasks/{task_id}/message")
+async def send_task_message(request: Request, task_id: str):
+ """Queue a user message for a running task to consume."""
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None or not _task_belongs_to_user(session, orm, user.user_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+
+ message = ""
+ content_type = request.headers.get("content-type", "")
+ if "application/json" in content_type:
+ try:
+ body = await request.json()
+ message = str(body.get("message") or body.get("prompt") or "")
+ except Exception:
+ message = ""
+ else:
+ form = await request.form()
+ message = str(form.get("message") or form.get("prompt") or "")
+
+ message = message.strip()
+ if not message:
+ raise HTTPException(status_code=400, detail="Message is required")
+
+ redis = aioredis.from_url(REDIS_URL, decode_responses=True)
+ try:
+ await redis.rpush(f"task:{task_id}:inbox", message)
+ finally:
+ await redis.aclose()
+
+ return {"id": task_id, "queued": True}
+
+
+@app.post("/api/conversations/{conversation_id}/messages")
+async def send_conversation_message(request: Request, conversation_id: str):
+ """OpenHands-style pending message endpoint.
+
+ If the conversation has a running task, queue the message into that task inbox.
+ Otherwise create a new task in the same conversation.
+ """
+ user = _require_user(request)
+ body = await request.json()
+ message = str(body.get("message") or body.get("prompt") or "").strip()
+ requested_model = str(body.get("model") or "").strip() or None
+ if not message:
+ raise HTTPException(status_code=400, detail="Message is required")
+
+ with registry.SessionLocal() as session:
+ conversation = _require_conversation(session, user.user_id, conversation_id)
+ active_task = next(
+ (
+ task
+ for task in reversed(
+ _conversation_tasks(
+ session, str(conversation.conversation_id), ascending=True
+ )
+ )
+ if task.status not in TERMINAL_STATUSES
+ ),
+ None,
+ )
+ if active_task is not None:
+ age_seconds = (_now() - (active_task.updated_at or _now())).total_seconds()
+ if active_task.status == "CREATED" and age_seconds > 300:
+ active_task.status = "FAILED"
+ active_task.result = {
+ "error": "Stale task recovered after worker interruption."
+ }
+ session.commit()
+ active_task = None
+
+ if active_task is not None:
+ active_task_id = str(active_task.task_id)
+ else:
+ active_task_id = None
+ run_model = requested_model or conversation.model
+ if requested_model:
+ conversation.model = requested_model
+ disabled_tools = list(
+ (conversation.settings or {}).get("disabled_tools", [])
+ )
+ requested_context_window = (conversation.settings or {}).get(
+ "requested_context_window"
+ )
+ auto_context_compress = (conversation.settings or {}).get(
+ "auto_context_compress", True
+ )
+ disabled_skills = list(
+ (conversation.settings or {}).get("disabled_skills", [])
+ )
+ enable_vendor_skills = bool(
+ (conversation.settings or {}).get("enable_vendor_skills", True)
+ )
+ pinned_skills = list((conversation.settings or {}).get("pinned_skills", []))
+ identity_notes = str(
+ (conversation.settings or {}).get("identity_notes", "")
+ )
+ conversation.updated_at = _now()
+ session.commit()
+
+ if active_task_id:
+ redis = aioredis.from_url(REDIS_URL, decode_responses=True)
+ try:
+ await redis.rpush(f"task:{active_task_id}:inbox", message)
+ finally:
+ await redis.aclose()
+ return {
+ "conversation_id": conversation_id,
+ "task_id": active_task_id,
+ "queued": True,
+ "created_task": False,
+ }
+
+ task = registry.create_task(
+ input=_task_input(
+ message,
+ conversation_id,
+ model=run_model,
+ disabled_tools=disabled_tools,
+ requested_context_window=requested_context_window,
+ auto_context_compress=auto_context_compress,
+ disabled_skills=disabled_skills,
+ enable_vendor_skills=enable_vendor_skills,
+ pinned_skills=pinned_skills,
+ identity_notes=identity_notes,
+ )
+ )
+ task.status = TaskStatus.CREATED
+ run_task.apply_async(args=[task.id, message], task_id=task.id)
+ return {
+ "conversation_id": conversation_id,
+ "task_id": task.id,
+ "queued": True,
+ "created_task": True,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Single task detail
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/tasks/{task_id}")
+async def get_task(request: Request, task_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None or not _task_belongs_to_user(session, orm, user.user_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ return {
+ "id": str(orm.task_id),
+ "status": orm.status,
+ "result": orm.result,
+ "conversation_id": _conversation_id_for(orm),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Interrupt / terminate
+# ---------------------------------------------------------------------------
+
+
+@app.post("/api/tasks/{task_id}/interrupt")
+@app.post("/api/tasks/{task_id}/terminate")
+async def interrupt_task(request: Request, task_id: str):
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None or not _task_belongs_to_user(session, orm, user.user_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ orm.status = TaskStatus.INTERRUPTED.value
+ session.commit()
+ registry.interrupt_task(task_id)
+ try:
+ celery_app.control.revoke(task_id, terminate=True)
+ except Exception:
+ pass
+ return {"id": task_id, "status": TaskStatus.INTERRUPTED.value}
+
+
+@app.delete("/api/tasks/{task_id}")
+async def delete_task(request: Request, task_id: str):
+ """Delete a task and its Redis stream."""
+ import redis as redis_lib
+
+ user = _require_user(request)
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None or not _task_belongs_to_user(session, orm, user.user_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ session.query(ConversationEventORM).filter(
+ ConversationEventORM.task_id == orm.task_id
+ ).delete(synchronize_session=False)
+ session.delete(orm)
+ session.commit()
+ try:
+ r = redis_lib.from_url(REDIS_URL, decode_responses=True)
+ r.delete(f"task:{task_id}:stream")
+ r.delete(f"task:{task_id}:inbox")
+ r.close()
+ except Exception:
+ pass
+ return {"id": task_id, "deleted": True}
+
+
+@app.get("/api/workspace/{path:path}")
+async def get_workspace(path: str = ""):
+ """List files in /app/workspace or return file content."""
+ import datetime
+ import os
+
+ from fastapi.responses import FileResponse
+
+ base = "/app/workspace"
+ target = os.path.normpath(os.path.join(base, path)) if path else base
+
+ # Security: block path traversal
+ if not target.startswith(base):
+ raise HTTPException(status_code=400, detail="Invalid path")
+
+ if not os.path.exists(target):
+ return [] # Empty workspace
+
+ if os.path.isfile(target):
+ return FileResponse(target, filename=os.path.basename(target))
+
+ entries = []
+ try:
+ for entry in sorted(os.scandir(target), key=lambda e: (not e.is_dir(), e.name)):
+ s = entry.stat()
+ entries.append(
+ {
+ "name": entry.name,
+ "type": "directory" if entry.is_dir() else "file",
+ "size": s.st_size,
+ "modifiedTime": datetime.datetime.fromtimestamp(
+ s.st_mtime
+ ).isoformat(),
+ }
+ )
+ except PermissionError:
+ pass
+ return entries
+
+
+# ---------------------------------------------------------------------------
+# Health
+# ---------------------------------------------------------------------------
+
+
+@app.get("/api/health")
+async def health():
+ return {"status": "ok"}
+
+
+if __name__ == "__main__": # pragma: no cover
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/server/celery_app.py b/server/celery_app.py
new file mode 100644
index 000000000..23bbbdd5b
--- /dev/null
+++ b/server/celery_app.py
@@ -0,0 +1,27 @@
+import os
+
+from celery import Celery
+
+
+broker_url = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
+backend_url = os.getenv(
+ "CELERY_RESULT_BACKEND", os.getenv("DATABASE_URL", "redis://redis:6379/0")
+)
+
+celery_app = Celery(
+ "openmanus",
+ broker=broker_url,
+ backend=backend_url,
+ include=["server.tasks"],
+)
+
+celery_app.conf.update(
+ task_serializer="json",
+ accept_content=["json"],
+ result_serializer="json",
+ timezone="UTC",
+ task_always_eager=False,
+)
+
+
+__all__ = ["celery_app"]
diff --git a/server/models.py b/server/models.py
new file mode 100644
index 000000000..c87012349
--- /dev/null
+++ b/server/models.py
@@ -0,0 +1,150 @@
+import uuid
+
+from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, String, func
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import declarative_base
+
+
+Base = declarative_base()
+
+
+class TaskORM(Base):
+ __tablename__ = "tasks"
+
+ task_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ status = Column(String, nullable=False)
+ input = Column(JSONB, nullable=True)
+ result = Column(JSONB, nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class UserORM(Base):
+ __tablename__ = "users"
+
+ user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email = Column(String, nullable=False, unique=True, index=True)
+ name = Column(String, nullable=False)
+ password_hash = Column(String, nullable=False)
+ role = Column(String, nullable=False, default="user")
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class SessionORM(Base):
+ __tablename__ = "sessions"
+
+ token = Column(String, primary_key=True)
+ user_id = Column(
+ UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False, index=True
+ )
+ expires_at = Column(DateTime(timezone=True), nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+
+class ConversationORM(Base):
+ __tablename__ = "conversations"
+
+ conversation_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id = Column(
+ UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False, index=True
+ )
+ title = Column(String, nullable=False, default="New conversation")
+ model = Column(String, nullable=True)
+ settings = Column(JSONB, nullable=False, default=dict)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class ConversationEventORM(Base):
+ __tablename__ = "conversation_events"
+
+ event_id = Column(BigInteger, primary_key=True, autoincrement=True)
+ conversation_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("conversations.conversation_id"),
+ nullable=False,
+ index=True,
+ )
+ task_id = Column(
+ UUID(as_uuid=True), ForeignKey("tasks.task_id"), nullable=True, index=True
+ )
+ event_type = Column(String, nullable=False, index=True)
+ payload = Column(JSONB, nullable=False, default=dict)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+
+
+class AppSettingORM(Base):
+ __tablename__ = "app_settings"
+
+ key = Column(String, primary_key=True)
+ value = Column(JSONB, nullable=False, default=dict)
+ updated_at = Column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class ObsidianNoteORM(Base):
+ __tablename__ = "obsidian_notes"
+
+ note_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ conversation_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("conversations.conversation_id"),
+ nullable=False,
+ index=True,
+ )
+ path = Column(String, nullable=False, index=True)
+ title = Column(String, nullable=False, index=True)
+ content = Column(String, nullable=False, default="")
+ tags = Column(JSONB, nullable=False, default=list)
+ meta = Column(JSONB, nullable=False, default=dict)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+
+class ObsidianEdgeORM(Base):
+ __tablename__ = "obsidian_edges"
+
+ edge_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ conversation_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("conversations.conversation_id"),
+ nullable=False,
+ index=True,
+ )
+ source_note_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("obsidian_notes.note_id"),
+ nullable=False,
+ index=True,
+ )
+ target_note_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("obsidian_notes.note_id"),
+ nullable=False,
+ index=True,
+ )
+ relation = Column(String, nullable=False, default="wikilink")
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+
+__all__ = [
+ "Base",
+ "TaskORM",
+ "UserORM",
+ "SessionORM",
+ "ConversationORM",
+ "ConversationEventORM",
+ "AppSettingORM",
+ "ObsidianNoteORM",
+ "ObsidianEdgeORM",
+]
diff --git a/server/tasks.py b/server/tasks.py
new file mode 100644
index 000000000..598c0732e
--- /dev/null
+++ b/server/tasks.py
@@ -0,0 +1,995 @@
+import asyncio
+import json
+import os
+import re
+import time
+import urllib.parse
+import urllib.request
+import uuid
+from pathlib import Path
+from typing import Optional
+
+import redis as redis_lib
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.agent.manus import Manus
+from app.config import config
+from app.memory.agentmemory import agentmemory
+from app.runtime_settings import get_disabled_tools, get_llm_connection
+from app.sandbox.conversation import ConversationSandbox
+from app.skills import format_skill_context, load_skills, select_skills
+from app.task_context import (
+ current_auto_context_compress,
+ current_llm_connection,
+ current_model,
+ current_requested_context_window,
+ current_sandbox,
+ current_task,
+ current_workspace,
+)
+from core.task import TaskStatus
+from core.task_registry import TaskRegistry
+from server.celery_app import celery_app
+from server.models import (
+ ConversationEventORM,
+ ConversationORM,
+ ObsidianEdgeORM,
+ ObsidianNoteORM,
+ TaskORM,
+)
+
+
+registry = TaskRegistry()
+
+REDIS_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
+DEFAULT_CONVERSATION_ID = os.getenv("OPENMANUS_DEFAULT_CONVERSATION_ID", "main")
+TASK_HARD_TIMEOUT_SECONDS = int(
+ os.getenv("OPENMANUS_TASK_HARD_TIMEOUT_SECONDS", "1800")
+)
+_redis_client: Optional[redis_lib.Redis] = None
+WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[^\]]*)\]\]")
+TAG_RE = re.compile(r"(^|\s)#([A-Za-z0-9_\-\/]+)")
+
+
+def _lmstudio_native_base(base_url: str) -> Optional[str]:
+ try:
+ parsed = urllib.parse.urlparse((base_url or "").strip())
+ except Exception:
+ return None
+ if not parsed.scheme or not parsed.netloc:
+ return None
+ root = f"{parsed.scheme}://{parsed.netloc}"
+ return f"{root}/api/v1"
+
+
+def _http_json(
+ method: str,
+ url: str,
+ payload: Optional[dict] = None,
+ token: Optional[str] = None,
+ timeout: int = 10,
+) -> dict:
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(url, method=method, headers=headers, data=body)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = resp.read()
+ return json.loads(data.decode("utf-8")) if data else {}
+
+
+def _connection_candidates(primary: dict) -> list[dict]:
+ base = dict(primary or {})
+ chain = base.get("fallback_chain", [])
+ if isinstance(chain, str):
+ try:
+ chain = json.loads(chain)
+ except Exception:
+ chain = []
+ candidates = [base]
+ if isinstance(chain, list):
+ for item in chain:
+ if isinstance(item, dict):
+ merged = dict(base)
+ merged.update(item)
+ candidates.append(merged)
+ deduped: list[dict] = []
+ seen: set[tuple[str, str, str]] = set()
+ for candidate in candidates:
+ key = (
+ str(candidate.get("api_type") or "").strip().lower(),
+ str(candidate.get("base_url") or "").strip(),
+ str(candidate.get("model") or "").strip(),
+ )
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(candidate)
+ return deduped
+
+
+def _is_connection_healthy(connection: dict, timeout: int = 5) -> tuple[bool, str]:
+ base_url = str(connection.get("base_url") or "").strip()
+ api_key = str(connection.get("api_key") or "").strip() or None
+ api_type = str(connection.get("api_type") or "").strip().lower()
+ if not base_url:
+ return False, "missing base_url"
+ try:
+ if api_type in {"lmstudio", "local"}:
+ native = _lmstudio_native_base(base_url)
+ if not native:
+ return False, "invalid lmstudio base_url"
+ _http_json("GET", f"{native}/models", token=api_key, timeout=timeout)
+ return True, "lmstudio /models ok"
+ # OpenAI-compatible default
+ _http_json(
+ "GET",
+ base_url.rstrip("/") + "/models"
+ if base_url.rstrip("/").endswith("/v1")
+ else base_url.rstrip("/") + "/v1/models",
+ token=api_key,
+ timeout=timeout,
+ )
+ return True, "v1/models ok"
+ except Exception as exc:
+ return False, str(exc)
+
+
+def resolve_llm_connection(connection: dict, task) -> dict:
+ candidates = _connection_candidates(connection or {})
+ for index, candidate in enumerate(candidates):
+ ok, detail = _is_connection_healthy(candidate)
+ task.emit(
+ "agent_state",
+ {
+ "state": "llm_preflight",
+ "candidate_index": index,
+ "api_type": str(candidate.get("api_type") or ""),
+ "base_url": str(candidate.get("base_url") or ""),
+ "ok": ok,
+ "detail": detail,
+ },
+ )
+ if ok:
+ selected = dict(candidate)
+ selected.pop("fallback_chain", None)
+ task.emit(
+ "agent_state",
+ {
+ "state": "llm_selected",
+ "candidate_index": index,
+ "api_type": str(selected.get("api_type") or ""),
+ "base_url": str(selected.get("base_url") or ""),
+ "model": str(selected.get("model") or ""),
+ },
+ )
+ return selected
+ # No healthy candidate; fall back to original so existing behavior remains.
+ fallback = dict(connection or {})
+ fallback.pop("fallback_chain", None)
+ task.emit(
+ "agent_state",
+ {
+ "state": "llm_selected",
+ "candidate_index": -1,
+ "api_type": str(fallback.get("api_type") or ""),
+ "base_url": str(fallback.get("base_url") or ""),
+ "model": str(fallback.get("model") or ""),
+ "detail": "No healthy fallback candidate; using primary settings.",
+ },
+ )
+ return fallback
+
+
+def _persist_received_context_window(conversation_id: str, value: int) -> None:
+ try:
+ cid = uuid.UUID(str(conversation_id))
+ except Exception:
+ return
+ try:
+ with registry.SessionLocal() as session:
+ conversation = session.get(ConversationORM, cid)
+ if conversation is None:
+ return
+ settings = dict(conversation.settings or {})
+ settings["received_context_window"] = int(value)
+ settings["received_context_window_source"] = "lmstudio_load"
+ conversation.settings = settings
+ session.commit()
+ except Exception:
+ return
+
+
+def sync_lmstudio_context_window(
+ *,
+ conversation_id: str,
+ requested_context_window: Optional[int],
+ llm_connection: dict,
+ model: Optional[str],
+ task,
+) -> Optional[int]:
+ """For LM Studio connections, load model with requested context_length and return applied value."""
+ if not requested_context_window or requested_context_window <= 0:
+ return None
+
+ connection = llm_connection or {}
+ default_llm = config.llm.get("default") if isinstance(config.llm, dict) else None
+
+ base_url = str(
+ connection.get("base_url") or getattr(default_llm, "base_url", "") or ""
+ )
+ api_type = str(
+ connection.get("api_type") or getattr(default_llm, "api_type", "") or ""
+ ).lower()
+ api_key = str(
+ connection.get("api_key") or getattr(default_llm, "api_key", "") or ""
+ )
+ selected_model = str(
+ model or connection.get("model") or getattr(default_llm, "model", "") or ""
+ ).strip()
+
+ native_base = _lmstudio_native_base(base_url)
+ is_lmstudio = api_type in {"lmstudio", "local", "openai"} and (
+ ":1234" in base_url
+ or "lmstudio" in base_url
+ or "localhost" in base_url
+ or "127.0.0.1" in base_url
+ )
+ if not native_base or not is_lmstudio or not selected_model:
+ return None
+
+ try:
+ response = _http_json(
+ "POST",
+ f"{native_base}/models/load",
+ payload={
+ "model": selected_model,
+ "context_length": int(requested_context_window),
+ "echo_load_config": True,
+ },
+ token=api_key or None,
+ timeout=20,
+ )
+ load_config = response.get("load_config") if isinstance(response, dict) else {}
+ received = load_config.get("context_length") or response.get("context_length")
+ if received in (None, ""):
+ return None
+ received_value = int(received)
+ _persist_received_context_window(conversation_id, received_value)
+ task.emit(
+ "context_window_received",
+ {
+ "requested_window": int(requested_context_window),
+ "received_window": received_value,
+ "model": selected_model,
+ "source": "lmstudio_load",
+ },
+ )
+ return received_value
+ except Exception as exc:
+ task.emit(
+ "context_window_received",
+ {
+ "requested_window": int(requested_context_window),
+ "received_window": None,
+ "model": selected_model,
+ "source": "lmstudio_load",
+ "error": str(exc),
+ },
+ )
+ return None
+
+
+def get_redis() -> redis_lib.Redis:
+ global _redis_client
+ if _redis_client is None:
+ _redis_client = redis_lib.from_url(REDIS_URL, decode_responses=True)
+ return _redis_client
+
+
+def publish_event(task_id: str, event_type: str, data: dict) -> None:
+ """Append an agent event to a Redis Stream so SSE can replay from the start."""
+ stream_key = f"task:{task_id}:stream"
+ payload = data if isinstance(data, dict) else {"value": str(data)}
+ try:
+ get_redis().xadd(
+ stream_key,
+ {"type": event_type, "data": json.dumps(payload)},
+ maxlen=500, # cap stream size
+ )
+ except Exception:
+ pass # Never let Redis failures crash the worker
+ try:
+ conversation_id = payload.get("conversation_id") or get_conversation_id(task_id)
+ with registry.SessionLocal() as session:
+ session.add(
+ ConversationEventORM(
+ conversation_id=uuid.UUID(str(conversation_id)),
+ task_id=uuid.UUID(str(task_id)),
+ event_type=event_type,
+ payload=payload,
+ )
+ )
+ session.commit()
+ except (SQLAlchemyError, Exception):
+ pass
+
+
+def summarize_workspace(workspace_root: Path) -> dict:
+ """Collect a small artifact summary for the completion event."""
+ if not workspace_root.exists():
+ return {"pdfs": [], "tex": [], "logs": [], "warning": "Workspace not found."}
+
+ def _relative_files(pattern: str) -> list[str]:
+ return sorted(
+ str(path.relative_to(workspace_root))
+ for path in workspace_root.rglob(pattern)
+ if path.is_file()
+ )
+
+ pdfs = _relative_files("*.pdf")
+ tex_files = _relative_files("*.tex")
+ logs = _relative_files("*.log")
+ warning = None
+
+ if not pdfs and (tex_files or logs):
+ warning = (
+ "No PDF was found in the task workspace. "
+ "LaTeX may have failed; check the terminal output or .log files."
+ )
+
+ return {"pdfs": pdfs, "tex": tex_files, "logs": logs, "warning": warning}
+
+
+def get_task_record(task_id: str) -> Optional[TaskORM]:
+ with registry.SessionLocal() as session:
+ orm = session.get(TaskORM, task_id)
+ if orm is None:
+ return None
+ session.expunge(orm)
+ return orm
+
+
+def get_conversation_id(task_id: str) -> str:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return DEFAULT_CONVERSATION_ID
+ task_input = orm.input or {}
+ if task_input.get("conversation_id"):
+ return str(task_input.get("conversation_id"))
+ if os.getenv("OPENMANUS_SINGLE_CONVERSATION", "true").lower() != "false":
+ return DEFAULT_CONVERSATION_ID
+ return str(task_input.get("conversation_id") or DEFAULT_CONVERSATION_ID)
+
+
+def get_task_model(task_id: str) -> Optional[str]:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return None
+ task_input = orm.input or {}
+ return task_input.get("model")
+
+
+def get_task_disabled_tools(task_id: str) -> set[str]:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return set()
+ task_input = orm.input or {}
+ return {str(name) for name in task_input.get("disabled_tools", [])}
+
+
+def get_task_requested_context_window(task_id: str) -> Optional[int]:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return None
+ task_input = orm.input or {}
+ raw = task_input.get("requested_context_window")
+ if raw in (None, ""):
+ return None
+ try:
+ value = int(raw)
+ except (TypeError, ValueError):
+ return None
+ return value if value > 0 else None
+
+
+def get_task_auto_context_compress(task_id: str) -> bool:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return True
+ task_input = orm.input or {}
+ raw = task_input.get("auto_context_compress")
+ if raw is None:
+ return True
+ return bool(raw)
+
+
+def get_task_disabled_skills(task_id: str) -> set[str]:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return set()
+ task_input = orm.input or {}
+ return {
+ str(name).strip()
+ for name in task_input.get("disabled_skills", [])
+ if str(name).strip()
+ }
+
+
+def get_task_enable_vendor_skills(task_id: str) -> bool:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return True
+ task_input = orm.input or {}
+ raw = task_input.get("enable_vendor_skills")
+ if raw is None:
+ return True
+ return bool(raw)
+
+
+def get_task_pinned_skills(task_id: str) -> list[str]:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return []
+ task_input = orm.input or {}
+ return [
+ str(name).strip()
+ for name in task_input.get("pinned_skills", [])
+ if str(name).strip()
+ ]
+
+
+def get_task_identity_notes(task_id: str) -> str:
+ orm = get_task_record(task_id)
+ if orm is None:
+ return ""
+ task_input = orm.input or {}
+ return str(task_input.get("identity_notes") or "").strip()
+
+
+def conversation_workspace(conversation_id: str) -> Path:
+ return (
+ Path(os.getenv("OPENMANUS_WORKSPACE_ROOT", "/app/workspace"))
+ / "conversations"
+ / conversation_id
+ )
+
+
+def host_conversation_workspace(conversation_id: str) -> Path:
+ return (
+ Path(os.getenv("OPENMANUS_HOST_WORKSPACE_ROOT", "/app/workspace"))
+ / "conversations"
+ / conversation_id
+ )
+
+
+def build_conversation_context(
+ task_id: str, conversation_id: str, workspace_root: Path
+) -> str:
+ """Build compact continuity context for follow-up tasks in the same conversation."""
+ with registry.SessionLocal() as session:
+ rows = session.query(TaskORM).order_by(TaskORM.created_at.asc()).all()
+
+ conversation_rows = [
+ row
+ for row in rows
+ if (
+ DEFAULT_CONVERSATION_ID
+ if os.getenv("OPENMANUS_SINGLE_CONVERSATION", "true").lower() != "false"
+ else str(
+ (row.input or {}).get("conversation_id") or DEFAULT_CONVERSATION_ID
+ )
+ )
+ == conversation_id
+ and str(row.task_id) != task_id
+ ][-6:]
+
+ if not conversation_rows and not workspace_root.exists():
+ return ""
+
+ lines = [
+ "Conversation continuity context:",
+ f"- Shared workspace: {workspace_root}",
+ "- Treat this as the same ongoing project. Reuse existing files and state.",
+ "- Do not restart prior work unless the user explicitly asks; continue from the latest result.",
+ ]
+
+ if conversation_rows:
+ lines.append("- Previous tasks in this conversation:")
+ for row in conversation_rows:
+ task_input = row.input or {}
+ result = row.result or {}
+ output = str(result.get("output") or result.get("error") or "")
+ output = output.replace("\n", " ")
+ if len(output) > 500:
+ output = output[:500] + "..."
+ lines.append(
+ f" - {row.status}: {task_input.get('prompt', '(no prompt)')} | {output}"
+ )
+
+ if workspace_root.exists():
+ files = sorted(
+ str(path.relative_to(workspace_root))
+ for path in workspace_root.rglob("*")
+ if path.is_file()
+ )[:80]
+ if files:
+ lines.append("- Current workspace files:")
+ lines.extend(f" - {file}" for file in files)
+
+ return "\n".join(lines)
+
+
+def build_obsidian_context(conversation_id: str, max_notes: int = 6) -> str:
+ """Build compact persisted Obsidian context from DB notes."""
+ with registry.SessionLocal() as session:
+ rows = (
+ session.query(ObsidianNoteORM)
+ .filter(ObsidianNoteORM.conversation_id == uuid.UUID(str(conversation_id)))
+ .order_by(ObsidianNoteORM.updated_at.desc())
+ .limit(max(1, min(max_notes, 20)))
+ .all()
+ )
+
+ if not rows:
+ return ""
+
+ lines = [
+ "Persistent vault context (Obsidian import):",
+ "- Use these notes as durable long-term memory for this conversation.",
+ ]
+ for note in rows:
+ snippet = (note.content or "").replace("\n", " ").strip()
+ if len(snippet) > 280:
+ snippet = snippet[:280] + "..."
+ tags = ", ".join(str(tag) for tag in (note.tags or [])[:6])
+ tags_part = f" | tags: {tags}" if tags else ""
+ lines.append(f"- {note.title} ({note.path}){tags_part}: {snippet}")
+ return "\n".join(lines)
+
+
+def build_agentmemory_context(conversation_id: str, prompt: str) -> str:
+ """Build optional long-term recall context from AgentMemory."""
+ if not prompt or not agentmemory.enabled:
+ return ""
+ return agentmemory.format_context(conversation_id=conversation_id, query=prompt)
+
+
+def build_identity_context(identity_notes: str) -> str:
+ if not identity_notes:
+ return ""
+ return f"Persistent user profile/context for this conversation:\n{identity_notes}"
+
+
+def update_skill_suggestions(conversation_id: str, task_id: str, prompt: str) -> None:
+ if not prompt.strip():
+ return
+ try:
+ cid = uuid.UUID(str(conversation_id))
+ tid = uuid.UUID(str(task_id))
+ except Exception:
+ return
+ try:
+ with registry.SessionLocal() as session:
+ rows = (
+ session.query(ConversationEventORM)
+ .filter(
+ ConversationEventORM.conversation_id == cid,
+ ConversationEventORM.task_id == tid,
+ ConversationEventORM.event_type == "tool_result",
+ )
+ .all()
+ )
+ tools: list[str] = []
+ for row in rows:
+ payload = row.payload or {}
+ tool = str(payload.get("tool") or "").strip()
+ if tool and tool not in tools:
+ tools.append(tool)
+ if len(tools) < 2:
+ return
+ conversation = session.get(ConversationORM, cid)
+ if conversation is None:
+ return
+ settings = dict(conversation.settings or {})
+ if not bool(settings.get("auto_skill_curator", True)):
+ return
+ suggestions = settings.get("skill_suggestions", [])
+ if not isinstance(suggestions, list):
+ suggestions = []
+ key = "|".join(tools[:6])
+ now = int(time.time())
+ updated = False
+ for item in suggestions:
+ if not isinstance(item, dict):
+ continue
+ if item.get("key") == key:
+ item["count"] = int(item.get("count") or 0) + 1
+ item["last_seen"] = now
+ item["last_prompt"] = prompt[:180]
+ updated = True
+ break
+ if not updated:
+ suggestions.insert(
+ 0,
+ {
+ "key": key,
+ "tools": tools[:6],
+ "count": 1,
+ "last_seen": now,
+ "last_prompt": prompt[:180],
+ },
+ )
+ settings["skill_suggestions"] = suggestions[:30]
+ conversation.settings = settings
+ session.commit()
+ except Exception:
+ return
+
+
+def auto_sync_obsidian_notes(conversation_id: str, workspace_root: Path) -> None:
+ """Automatically sync markdown notes from workspace into obsidian_notes + graph edges."""
+ if not workspace_root.exists():
+ return
+ markdown_files = [
+ path
+ for path in workspace_root.rglob("*.md")
+ if path.is_file()
+ and ".git/" not in str(path)
+ and "node_modules/" not in str(path)
+ and ".venv/" not in str(path)
+ ][:200]
+ if not markdown_files:
+ return
+
+ cid = uuid.UUID(str(conversation_id))
+ with registry.SessionLocal() as session:
+ existing_notes = (
+ session.query(ObsidianNoteORM)
+ .filter(ObsidianNoteORM.conversation_id == cid)
+ .all()
+ )
+ by_path = {note.path: note for note in existing_notes}
+ by_title = {note.title: note for note in existing_notes}
+ touched_paths: set[str] = set()
+ touched_notes: list[ObsidianNoteORM] = []
+
+ for file_path in markdown_files:
+ rel = str(file_path.relative_to(workspace_root))
+ touched_paths.add(rel)
+ try:
+ content = file_path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ continue
+ lines = content.splitlines()
+ first_heading = next(
+ (
+ line.lstrip("# ").strip()
+ for line in lines
+ if line.strip().startswith("#") and line.lstrip("# ").strip()
+ ),
+ "",
+ )
+ title = first_heading or file_path.stem
+ tags = sorted({match[1] for match in TAG_RE.findall(content)})[:40]
+ note = by_path.get(rel)
+ if note is None:
+ note = ObsidianNoteORM(
+ conversation_id=cid,
+ path=rel,
+ title=title,
+ content=content,
+ tags=tags,
+ meta={"source": "workspace-auto-sync"},
+ )
+ session.add(note)
+ else:
+ note.title = title
+ note.content = content
+ note.tags = tags
+ note.meta = {"source": "workspace-auto-sync"}
+ by_path[rel] = note
+ by_title[title] = note
+ touched_notes.append(note)
+
+ session.flush()
+
+ # keep manually imported notes untouched; only remove old auto-synced missing files
+ for old in existing_notes:
+ source = (old.meta or {}).get("source")
+ if source == "workspace-auto-sync" and old.path not in touched_paths:
+ session.delete(old)
+
+ session.query(ObsidianEdgeORM).filter(
+ ObsidianEdgeORM.conversation_id == cid
+ ).delete()
+ for note in touched_notes:
+ content = note.content or ""
+ for link in WIKILINK_RE.findall(content):
+ target_name = str(link).strip()
+ target = by_title.get(target_name) or by_path.get(target_name)
+ if target is None:
+ continue
+ session.add(
+ ObsidianEdgeORM(
+ conversation_id=cid,
+ source_note_id=note.note_id,
+ target_note_id=target.note_id,
+ relation="wikilink",
+ )
+ )
+ session.commit()
+
+
+class RedisEmittingTask:
+ """
+ Wraps a Task so that every emit() is also written to a Redis Stream.
+ This allows the web server (different process) to stream events to the browser.
+ """
+
+ def __init__(self, task_id: str, inner_task):
+ self.id = task_id
+ self._inner = inner_task
+
+ def emit(self, type: str, data) -> None:
+ self._inner.emit(type, data)
+ publish_event(
+ self.id, type, data if isinstance(data, dict) else {"value": str(data)}
+ )
+
+ def is_interrupted(self) -> bool:
+ return self._inner.is_interrupted()
+
+ def interrupt(self) -> None:
+ self._inner.interrupt()
+
+ def __getattr__(self, name):
+ return getattr(self._inner, name)
+
+
+@celery_app.task(name="run_task")
+def run_task(task_id: str, prompt: Optional[str] = None):
+ """Celery task: run Manus agent and write events to Redis Stream."""
+ task = registry.get_task(task_id)
+ if task is None:
+ return {"error": "task not found"}
+
+ wrapped = RedisEmittingTask(task_id, task)
+ conversation_id = get_conversation_id(task_id)
+ model = get_task_model(task_id)
+ disabled_tools = get_disabled_tools() | get_task_disabled_tools(task_id)
+ requested_context_window = get_task_requested_context_window(task_id)
+ auto_context_compress = get_task_auto_context_compress(task_id)
+ disabled_skills = get_task_disabled_skills(task_id)
+ enable_vendor_skills = get_task_enable_vendor_skills(task_id)
+ pinned_skills = get_task_pinned_skills(task_id)
+ identity_notes = get_task_identity_notes(task_id)
+ llm_connection = get_llm_connection()
+ workspace_root = conversation_workspace(conversation_id)
+ host_workspace_root = host_conversation_workspace(conversation_id)
+
+ async def _run():
+ workspace_root.mkdir(parents=True, exist_ok=True)
+ sandbox = None
+ sandbox_token = None
+ workspace_token = current_workspace.set(str(workspace_root))
+ model_token = current_model.set(model)
+ requested_context_window_token = current_requested_context_window.set(
+ requested_context_window
+ )
+ auto_context_compress_token = current_auto_context_compress.set(
+ auto_context_compress
+ )
+ selected_connection = resolve_llm_connection(llm_connection, wrapped)
+ llm_connection_token = current_llm_connection.set(selected_connection)
+ if config.sandbox.use_sandbox:
+ sandbox = await ConversationSandbox(
+ conversation_id=conversation_id,
+ host_workspace=host_workspace_root,
+ config=config.sandbox,
+ ).ensure()
+ sandbox_token = current_sandbox.set(sandbox)
+
+ previous_cwd = os.getcwd()
+ os.chdir(workspace_root)
+ token = current_task.set(wrapped)
+ try:
+ sync_lmstudio_context_window(
+ conversation_id=conversation_id,
+ requested_context_window=requested_context_window,
+ llm_connection=selected_connection,
+ model=model,
+ task=wrapped,
+ )
+ auto_sync_obsidian_notes(conversation_id, workspace_root)
+ continuity = build_conversation_context(
+ task_id, conversation_id, workspace_root
+ )
+ obsidian_context = build_obsidian_context(conversation_id)
+ agentmemory_context = build_agentmemory_context(
+ conversation_id, prompt or ""
+ )
+ identity_context = build_identity_context(identity_notes)
+ selected_skills = select_skills(
+ prompt or "",
+ workspace_root,
+ include_vendor=enable_vendor_skills,
+ disabled_skills=disabled_skills,
+ )
+ if pinned_skills:
+ pool = load_skills(
+ workspace_root,
+ include_vendor=enable_vendor_skills,
+ disabled_skills=disabled_skills,
+ )
+ names = {skill.name: skill for skill in pool}
+ ordered = [names[name] for name in pinned_skills if name in names]
+ for skill in selected_skills:
+ if skill.name not in {item.name for item in ordered}:
+ ordered.append(skill)
+ selected_skills = ordered
+ skill_context = format_skill_context(selected_skills)
+ if sandbox is not None:
+ continuity = continuity.replace(
+ str(workspace_root), config.sandbox.work_dir
+ )
+ skill_context = skill_context.replace(
+ str(workspace_root), config.sandbox.work_dir
+ )
+ context_parts = [
+ part
+ for part in (
+ continuity,
+ obsidian_context,
+ agentmemory_context,
+ identity_context,
+ skill_context,
+ )
+ if part
+ ]
+ combined_context = "\n\n".join(context_parts)
+ run_prompt = (
+ f"{combined_context}\n\nCurrent user request:\n{prompt}"
+ if context_parts and prompt
+ else prompt
+ )
+ agent_workspace = (
+ config.sandbox.work_dir if sandbox is not None else str(workspace_root)
+ )
+ agent = await Manus.create(
+ workspace_root=agent_workspace,
+ disabled_tools=disabled_tools,
+ )
+ result = await agent.run(wrapped, run_prompt)
+ return result
+ finally:
+ current_task.reset(token)
+ if sandbox_token is not None:
+ current_sandbox.reset(sandbox_token)
+ current_workspace.reset(workspace_token)
+ current_model.reset(model_token)
+ current_requested_context_window.reset(requested_context_window_token)
+ current_auto_context_compress.reset(auto_context_compress_token)
+ current_llm_connection.reset(llm_connection_token)
+ os.chdir(previous_cwd)
+
+ try:
+ task.status = "RUNNING"
+ registry.update_task(task)
+ wrapped.emit(
+ "agent_state", {"state": "run_started", "conversation_id": conversation_id}
+ )
+ if config.agentmemory.enabled and prompt:
+ agentmemory.remember(
+ conversation_id=conversation_id,
+ title="User request",
+ content=str(prompt),
+ metadata={"task_id": task_id, "status": "STARTED"},
+ )
+ result = asyncio.run(
+ asyncio.wait_for(_run(), timeout=max(30, TASK_HARD_TIMEOUT_SECONDS))
+ )
+ task.status = "COMPLETED"
+ workspace_summary = summarize_workspace(workspace_root)
+ result_text = str(result or "").strip()
+ completion_message = result_text if result_text else "Task completed."
+ if config.agentmemory.enabled and config.agentmemory.auto_remember_completion:
+ agentmemory.remember(
+ conversation_id=conversation_id,
+ title="Task completion summary",
+ content=completion_message,
+ metadata={"task_id": task_id, "status": "COMPLETED"},
+ )
+ update_skill_suggestions(conversation_id, task_id, prompt or "")
+ registry.update_task(
+ task,
+ result={
+ "output": result,
+ "workspace": workspace_summary,
+ "conversation_id": conversation_id,
+ },
+ )
+ publish_event(
+ task_id,
+ "finish_signal",
+ {
+ "message": completion_message,
+ "workspace": workspace_summary,
+ "conversation_id": conversation_id,
+ },
+ )
+ wrapped.emit(
+ "agent_state",
+ {"state": "run_completed", "conversation_id": conversation_id},
+ )
+ return {"status": "COMPLETED", "result": result}
+ except asyncio.TimeoutError:
+ detail = (
+ f"Task exceeded hard timeout ({max(30, TASK_HARD_TIMEOUT_SECONDS)}s) "
+ "and was terminated."
+ )
+ task.status = TaskStatus.FAILED
+ registry.update_task(task, result={"error": detail})
+ publish_event(
+ task_id,
+ "error",
+ {
+ "message": "Task failed",
+ "detail": detail,
+ "reason": detail,
+ "conversation_id": conversation_id,
+ },
+ )
+ wrapped.emit(
+ "agent_state",
+ {
+ "state": "run_failed",
+ "conversation_id": conversation_id,
+ "error": detail,
+ },
+ )
+ return {"status": "FAILED", "error": detail}
+ except Exception as exc:
+ task.status = TaskStatus.FAILED
+ registry.update_task(task, result={"error": str(exc)})
+ publish_event(
+ task_id,
+ "error",
+ {
+ "message": "Task failed",
+ "detail": str(exc),
+ "reason": str(exc),
+ "conversation_id": conversation_id,
+ },
+ )
+ wrapped.emit(
+ "agent_state",
+ {
+ "state": "run_failed",
+ "conversation_id": conversation_id,
+ "error": str(exc),
+ },
+ )
+ return {"status": "FAILED", "error": str(exc)}
+ finally:
+ # Last-resort guard: never leave task non-terminal.
+ if str(task.status) in {"CREATED", "RUNNING"}:
+ detail = "Task ended unexpectedly without terminal status."
+ task.status = TaskStatus.FAILED
+ registry.update_task(task, result={"error": detail})
+ publish_event(
+ task_id,
+ "error",
+ {
+ "message": "Task failed",
+ "detail": detail,
+ "reason": detail,
+ "conversation_id": conversation_id,
+ },
+ )
+ # Ensure worker process does not keep a closed loop as current loop.
+ try:
+ asyncio.set_event_loop(None)
+ except Exception:
+ pass
diff --git a/server/ws.py b/server/ws.py
new file mode 100644
index 000000000..c1e7554ce
--- /dev/null
+++ b/server/ws.py
@@ -0,0 +1,93 @@
+import asyncio
+import json
+import queue
+from typing import Any
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+from core.task_registry import TaskRegistry
+
+
+# Reuse registry from REST API if available; fallback to a local registry.
+try: # pragma: no cover - optional import
+ from server.api import registry as api_registry
+
+ registry = api_registry
+except Exception: # pragma: no cover - fallback
+ registry = TaskRegistry()
+
+app = FastAPI(title="OpenManus Task WS")
+
+
+async def _get_event(task_queue: Any):
+ """Await an event from either asyncio.Queue or queue.Queue."""
+ if isinstance(task_queue, asyncio.Queue):
+ return await task_queue.get()
+ if isinstance(task_queue, queue.Queue):
+ return await asyncio.to_thread(task_queue.get)
+ return None
+
+
+@app.websocket("/tasks/{task_id}/stream")
+async def task_stream(websocket: WebSocket, task_id: str):
+ await websocket.accept()
+ task = registry.get_task(task_id)
+ if not task:
+ await websocket.send_text(json.dumps({"error": "Task not found"}))
+ await websocket.close()
+ return
+
+ event_queue = task.event_queue
+
+ try:
+ while True:
+ recv_task = asyncio.create_task(websocket.receive_text())
+ event_task = asyncio.create_task(_get_event(event_queue))
+
+ done, pending = await asyncio.wait(
+ {recv_task, event_task}, return_when=asyncio.FIRST_COMPLETED
+ )
+
+ for p in pending:
+ p.cancel()
+
+ if recv_task in done:
+ try:
+ message = recv_task.result()
+ except WebSocketDisconnect:
+ return
+ except Exception:
+ message = ""
+
+ if isinstance(message, str):
+ try:
+ data = json.loads(message)
+ except json.JSONDecodeError:
+ data = {"command": message}
+ command = data.get("command")
+ if command == "interrupt":
+ registry.interrupt_task(task_id)
+ await websocket.send_text(
+ json.dumps({"type": "interrupt_ack", "id": task_id})
+ )
+
+ if event_task in done:
+ try:
+ event = event_task.result()
+ except Exception:
+ event = None
+
+ if event is None:
+ continue
+
+ payload = event if isinstance(event, dict) else {"event": event}
+ await websocket.send_text(json.dumps(payload))
+
+ except WebSocketDisconnect:
+ return
+
+
+if __name__ == "__main__": # pragma: no cover
+ import uvicorn
+
+ uvicorn.run(app, host="0.0.0.0", port=8001)
diff --git a/setup.py b/setup.py
index bf291e4f4..950f92cbd 100644
--- a/setup.py
+++ b/setup.py
@@ -21,6 +21,7 @@
"pyyaml~=6.0.2",
"loguru~=0.7.3",
"numpy",
+ "faiss-cpu",
"datasets>=3.2,<3.5",
"html2text~=2024.2.26",
"gymnasium>=1.0,<1.2",
diff --git a/test_playwright.py b/test_playwright.py
new file mode 100644
index 000000000..b8db99186
--- /dev/null
+++ b/test_playwright.py
@@ -0,0 +1,11 @@
+from playwright.sync_api import sync_playwright
+
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ page = browser.new_page()
+ page.goto("https://html.duckduckgo.com/html/?q=test")
+ results = page.query_selector_all(".result__title a.result__a")
+ for r in results[:2]:
+ print(r.inner_text(), r.get_attribute("href"))
+ browser.close()
diff --git a/tests/sandbox/test_client.py b/tests/sandbox/test_client.py
index 6b2c61f2f..8477dd56b 100644
--- a/tests/sandbox/test_client.py
+++ b/tests/sandbox/test_client.py
@@ -38,7 +38,7 @@ async def test_sandbox_creation(local_client: LocalSandboxClient):
await local_client.create(config)
result = await local_client.run_command("python3 --version")
- assert "Python 3.10" in result
+ assert "Python 3.12" in result
@pytest.mark.asyncio
diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py
index b21dd6f30..ce8654385 100644
--- a/tests/sandbox/test_sandbox.py
+++ b/tests/sandbox/test_sandbox.py
@@ -91,7 +91,7 @@ async def test_sandbox_python_environment(sandbox):
"""Tests Python environment configuration."""
# Test Python version
result = await sandbox.terminal.run_command("python3 --version")
- assert "Python 3.10" in result
+ assert "Python 3.12" in result
# Test basic module imports
python_code = """
diff --git a/tests/test_agentmemory_faiss.py b/tests/test_agentmemory_faiss.py
new file mode 100644
index 000000000..411f4d950
--- /dev/null
+++ b/tests/test_agentmemory_faiss.py
@@ -0,0 +1,111 @@
+import os
+import json
+import tempfile
+import pytest
+from app.memory.agentmemory import AgentMemoryClient
+
+
+@pytest.fixture
+def temp_memory_client(monkeypatch):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = os.path.join(tmpdir, "test_memory.db")
+ faiss_path = os.path.join(tmpdir, "test_memory.faiss")
+ meta_path = os.path.join(tmpdir, "test_memory_vectors.json")
+
+ client = AgentMemoryClient()
+ client.enabled_in_config = True
+ client.db_path = db_path
+ client.settings.vector_backend = "faiss"
+ client.settings.vector_index_path = faiss_path
+ client.settings.vector_meta_path = meta_path
+ client.settings.hybrid_search = True
+ client._init_db()
+
+ # Mock embedding function to return deterministic vectors
+ def mock_embedding(text: str):
+ # Simple pseudo-embedding based on length and characters
+ val = float(len(text))
+ if "apple" in text.lower():
+ return [1.0, 0.0, 0.0, 0.0]
+ elif "banana" in text.lower():
+ return [0.0, 1.0, 0.0, 0.0]
+ elif "region" in text.lower() or "frankfurt" in text.lower() or "eu" in text.lower():
+ return [0.0, 0.0, 1.0, 0.0]
+ return [val, val, val, val]
+
+ monkeypatch.setattr(client, "_get_embedding", mock_embedding)
+ yield client
+
+
+def test_remember_and_search_hybrid(temp_memory_client):
+ client = temp_memory_client
+ cid = "conv-1"
+
+ success = client.remember(
+ conversation_id=cid,
+ title="Server location",
+ content="Our primary deployment region is Frankfurt.",
+ )
+ assert success is True
+
+ # Verify FAISS index file and meta file were created
+ assert os.path.exists(client.settings.vector_index_path)
+ assert os.path.exists(client.settings.vector_meta_path)
+
+ # Search with query that matches vector similarity (mock returns [0, 0, 1, 0] for 'eu')
+ hits = client.search(conversation_id=cid, query="Which EU data center is used?")
+ assert len(hits) > 0
+ assert "Frankfurt" in hits[0].content
+
+
+def test_faiss_fallback_on_error(temp_memory_client, monkeypatch):
+ client = temp_memory_client
+ cid = "conv-2"
+
+ client.remember(
+ conversation_id=cid,
+ title="Keyword Test",
+ content="UniqueWordXYZZY is present in this memory.",
+ )
+
+ # Force FAISS index read error
+ def failing_read(*args, **kwargs):
+ raise RuntimeError("Mock FAISS error")
+
+ import faiss
+ monkeypatch.setattr(faiss, "read_index", failing_read)
+
+ hits = client.search(conversation_id=cid, query="UniqueWordXYZZY")
+ assert len(hits) > 0
+ assert "UniqueWordXYZZY" in hits[0].content
+ assert client.last_vector_error is not None
+
+
+def test_rebuild_vector_index(temp_memory_client):
+ client = temp_memory_client
+ cid = "conv-3"
+
+ # Insert directly via SQLite to bypass remember vector indexing
+ with client._get_conn() as conn:
+ conn.execute(
+ """
+ INSERT INTO memories (conversation_id, project, title, content)
+ VALUES (?, ?, ?, ?)
+ """,
+ (cid, client.project, "Fruit", "Apple is delicious."),
+ )
+ conn.commit()
+
+ if os.path.exists(client.settings.vector_index_path):
+ os.remove(client.settings.vector_index_path)
+
+ count = client.rebuild_vector_index()
+ assert count == 1
+ assert os.path.exists(client.settings.vector_index_path)
+
+
+def test_vector_health(temp_memory_client):
+ client = temp_memory_client
+ health = client.get_vector_health()
+ assert health["vector_backend"] == "faiss"
+ assert health["vector_live"] is True
diff --git a/tools/runner.py b/tools/runner.py
new file mode 100644
index 000000000..435b0749e
--- /dev/null
+++ b/tools/runner.py
@@ -0,0 +1,95 @@
+import asyncio
+import io
+from contextlib import redirect_stderr, redirect_stdout
+from typing import Any, Mapping, Optional
+
+from app.agent.base import TaskInterrupted
+from app.tool.base import BaseTool, ToolResult
+from core.task import Task
+
+
+class ToolRunner:
+ """Unified tool execution entry with timeout, output capture, and interruption checks."""
+
+ def __init__(
+ self,
+ tools: Mapping[str, Any],
+ default_timeout: Optional[float] = None,
+ check_interval: float = 0.1,
+ ) -> None:
+ self.default_timeout = default_timeout
+ self.check_interval = check_interval
+ # Support ToolCollection or plain dict mapping
+ if hasattr(tools, "tool_map"):
+ self.tools = getattr(tools, "tool_map")
+ else:
+ self.tools = tools
+
+ async def run(
+ self, task: Task, tool_name: str, args: Optional[dict] = None
+ ) -> ToolResult:
+ if task.is_interrupted():
+ raise TaskInterrupted()
+
+ args = args or {}
+ tool = self.tools.get(tool_name) if hasattr(self.tools, "get") else None
+ if tool is None or not isinstance(tool, BaseTool):
+ return ToolResult(error=f"Tool '{tool_name}' not found")
+
+ async def _invoke():
+ buf_out, buf_err = io.StringIO(), io.StringIO()
+ with redirect_stdout(buf_out), redirect_stderr(buf_err):
+ result = await tool.execute(**args)
+ return result, buf_out.getvalue(), buf_err.getvalue()
+
+ tool_task = asyncio.create_task(_invoke())
+ start = asyncio.get_event_loop().time()
+ timeout = self.default_timeout
+
+ while True:
+ done, _ = await asyncio.wait(
+ {tool_task},
+ timeout=self.check_interval,
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ if task.is_interrupted():
+ tool_task.cancel()
+ raise TaskInterrupted()
+ if done:
+ break
+ if (
+ timeout is not None
+ and (asyncio.get_event_loop().time() - start) > timeout
+ ):
+ tool_task.cancel()
+ return ToolResult(
+ error=f"Tool '{tool_name}' execution timed out after {timeout} seconds"
+ )
+
+ try:
+ result, stdout_text, stderr_text = await tool_task
+ except asyncio.CancelledError:
+ return ToolResult(error=f"Tool '{tool_name}' execution cancelled")
+ except Exception as exc: # pragma: no cover - safety net
+ return ToolResult(error=f"Tool '{tool_name}' failed: {exc}")
+
+ system_info_parts = []
+ if stdout_text:
+ system_info_parts.append(f"stdout:\n{stdout_text}")
+ if stderr_text:
+ system_info_parts.append(f"stderr:\n{stderr_text}")
+ system_info = "\n".join(system_info_parts) if system_info_parts else None
+
+ if isinstance(result, ToolResult):
+ if system_info:
+ return result.replace(
+ system=system_info
+ if not result.system
+ else f"{result.system}\n{system_info}"
+ )
+ return result
+
+ return ToolResult(output=result, system=system_info)
+
+
+__all__ = ["ToolRunner"]
diff --git a/tools/shell.py b/tools/shell.py
new file mode 100644
index 000000000..999e0ff5f
--- /dev/null
+++ b/tools/shell.py
@@ -0,0 +1,45 @@
+from typing import Any, Dict, Optional
+
+from app.tool.base import ToolResult
+from app.tool.bash import Bash
+from core.task import Task
+from tools.runner import ToolRunner
+
+
+class ShellResult(ToolResult):
+ stdout: Optional[str] = None
+ stderr: Optional[str] = None
+ exit_code: Optional[int] = None
+
+ class Config:
+ arbitrary_types_allowed = True
+
+
+class Shell:
+ """Shell command runner that delegates to ToolRunner and Bash tool."""
+
+ def __init__(self, default_timeout: Optional[float] = None):
+ self.runner = ToolRunner({"bash": Bash()}, default_timeout=default_timeout)
+
+ async def run(
+ self, task: Task, command: str, timeout: Optional[float] = None
+ ) -> ShellResult:
+ args: Dict[str, Any] = {"command": command}
+ runner = self.runner
+ if timeout is not None:
+ runner = ToolRunner({"bash": runner.tools["bash"]}, default_timeout=timeout)
+
+ result = await runner.run(task, "bash", args)
+
+ return ShellResult(
+ output=result.output,
+ error=result.error,
+ system=result.system,
+ base64_image=result.base64_image,
+ stdout=result.output,
+ stderr=result.error,
+ exit_code=None,
+ )
+
+
+__all__ = ["Shell", "ShellResult"]
diff --git a/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/SKILL.md b/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/SKILL.md
new file mode 100644
index 000000000..fb668bcc9
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/SKILL.md
@@ -0,0 +1,152 @@
+---
+name: agent-introspection-debugging
+description: Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports.
+---
+
+# Agent Introspection Debugging
+
+Use this skill when an agent run is failing repeatedly, consuming tokens without progress, looping on the same tools, or drifting away from the intended task.
+
+This is a workflow skill, not a hidden runtime. It teaches the agent to debug itself systematically before escalating to a human.
+
+## When to Activate
+
+- Maximum tool call / loop-limit failures
+- Repeated retries with no forward progress
+- Context growth or prompt drift that starts degrading output quality
+- File-system or environment state mismatch between expectation and reality
+- Tool failures that are likely recoverable with diagnosis and a smaller corrective action
+
+## Scope Boundaries
+
+Activate this skill for:
+- capturing failure state before retrying blindly
+- diagnosing common agent-specific failure patterns
+- applying contained recovery actions
+- producing a structured human-readable debug report
+
+Do not use this skill as the primary source for:
+- feature verification after code changes; use `verification-loop`
+- framework-specific debugging when a narrower ECC skill already exists
+- runtime promises the current harness cannot enforce automatically
+
+## Four-Phase Loop
+
+### Phase 1: Failure Capture
+
+Before trying to recover, record the failure precisely.
+
+Capture:
+- error type, message, and stack trace when available
+- last meaningful tool call sequence
+- what the agent was trying to do
+- current context pressure: repeated prompts, oversized pasted logs, duplicated plans, or runaway notes
+- current environment assumptions: cwd, branch, relevant service state, expected files
+
+Minimum capture template:
+
+```markdown
+## Failure Capture
+- Session / task:
+- Goal in progress:
+- Error:
+- Last successful step:
+- Last failed tool / command:
+- Repeated pattern seen:
+- Environment assumptions to verify:
+```
+
+### Phase 2: Root-Cause Diagnosis
+
+Match the failure to a known pattern before changing anything.
+
+| Pattern | Likely Cause | Check |
+| --- | --- | --- |
+| Maximum tool calls / repeated same command | loop or no-exit observer path | inspect the last N tool calls for repetition |
+| Context overflow / degraded reasoning | unbounded notes, repeated plans, oversized logs | inspect recent context for duplication and low-signal bulk |
+| `ECONNREFUSED` / timeout | service unavailable or wrong port | verify service health, URL, and port assumptions |
+| `429` / quota exhaustion | retry storm or missing backoff | count repeated calls and inspect retry spacing |
+| file missing after write / stale diff | race, wrong cwd, or branch drift | re-check path, cwd, git status, and actual file existence |
+| tests still failing after “fix” | wrong hypothesis | isolate the exact failing test and re-derive the bug |
+
+Diagnosis questions:
+- is this a logic failure, state failure, environment failure, or policy failure?
+- did the agent lose the real objective and start optimizing the wrong subtask?
+- is the failure deterministic or transient?
+- what is the smallest reversible action that would validate the diagnosis?
+
+### Phase 3: Contained Recovery
+
+Recover with the smallest action that changes the diagnosis surface.
+
+Safe recovery actions:
+- stop repeated retries and restate the hypothesis
+- trim low-signal context and keep only the active goal, blockers, and evidence
+- re-check the actual filesystem / branch / process state
+- narrow the task to one failing command, one file, or one test
+- switch from speculative reasoning to direct observation
+- escalate to a human when the failure is high-risk or externally blocked
+
+Do not claim unsupported auto-healing actions like “reset agent state” or “update harness config” unless you are actually doing them through real tools in the current environment.
+
+Contained recovery checklist:
+
+```markdown
+## Recovery Action
+- Diagnosis chosen:
+- Smallest action taken:
+- Why this is safe:
+- What evidence would prove the fix worked:
+```
+
+### Phase 4: Introspection Report
+
+End with a report that makes the recovery legible to the next agent or human.
+
+```markdown
+## Agent Self-Debug Report
+- Session / task:
+- Failure:
+- Root cause:
+- Recovery action:
+- Result: success | partial | blocked
+- Token / time burn risk:
+- Follow-up needed:
+- Preventive change to encode later:
+```
+
+## Recovery Heuristics
+
+Prefer these interventions in order:
+
+1. Restate the real objective in one sentence.
+2. Verify the world state instead of trusting memory.
+3. Shrink the failing scope.
+4. Run one discriminating check.
+5. Only then retry.
+
+Bad pattern:
+- retrying the same action three times with slightly different wording
+
+Good pattern:
+- capture failure
+- classify the pattern
+- run one direct check
+- change the plan only if the check supports it
+
+## Integration with ECC
+
+- Use `verification-loop` after recovery if code was changed.
+- Use `continuous-learning-v2` when the failure pattern is worth turning into an instinct or later skill.
+- Use `council` when the issue is not technical failure but decision ambiguity.
+- Use `workspace-surface-audit` if the failure came from conflicting local state or repo drift.
+
+## Output Standard
+
+When this skill is active, do not end with “I fixed it” alone.
+
+Always provide:
+- the failure pattern
+- the root-cause hypothesis
+- the recovery action
+- the evidence that the situation is now better or still blocked
diff --git a/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/agents/openai.yaml
new file mode 100644
index 000000000..4d53a0d73
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/agent-introspection-debugging/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Agent Introspection Debugging"
+ short_description: "Structured self-debugging for AI agent failures"
+ brand_color: "#0EA5E9"
+ default_prompt: "Use $agent-introspection-debugging to diagnose and recover from an AI agent failure."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/agent-sort/SKILL.md b/vendor/everything-claude-code/agents-skills/agent-sort/SKILL.md
new file mode 100644
index 000000000..4daf0a7c2
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/agent-sort/SKILL.md
@@ -0,0 +1,214 @@
+---
+name: agent-sort
+description: Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle.
+---
+
+# Agent Sort
+
+Use this skill when a repo needs a project-specific ECC surface instead of the default full install.
+
+The goal is not to guess what "feels useful." The goal is to classify ECC components with evidence from the actual codebase.
+
+## When to Use
+
+- A project only needs a subset of ECC and full installs are too noisy
+- The repo stack is clear, but nobody wants to hand-curate skills one by one
+- A team wants a repeatable install decision backed by grep evidence instead of opinion
+- You need to separate always-loaded daily workflow surfaces from searchable library/reference surfaces
+- A repo has drifted into the wrong language, rule, or hook set and needs cleanup
+
+## Non-Negotiable Rules
+
+- Use the current repository as the source of truth, not generic preferences
+- Every DAILY decision must cite concrete repo evidence
+- LIBRARY does not mean "delete"; it means "keep accessible without loading by default"
+- Do not install hooks, rules, or scripts that the current repo cannot use
+- Prefer ECC-native surfaces; do not introduce a second install system
+
+## Outputs
+
+Produce these artifacts in order:
+
+1. DAILY inventory
+2. LIBRARY inventory
+3. install plan
+4. verification report
+5. optional `skill-library` router if the project wants one
+
+## Classification Model
+
+Use two buckets only:
+
+- `DAILY`
+ - should load every session for this repo
+ - strongly matched to the repo's language, framework, workflow, or operator surface
+- `LIBRARY`
+ - useful to retain, but not worth loading by default
+ - should remain reachable through search, router skill, or selective manual use
+
+## Evidence Sources
+
+Use repo-local evidence before making any classification:
+
+- file extensions
+- package managers and lockfiles
+- framework configs
+- CI and hook configs
+- build/test scripts
+- imports and dependency manifests
+- repo docs that explicitly describe the stack
+
+Useful commands include:
+
+```bash
+rg --files
+rg -n "typescript|react|next|supabase|django|spring|flutter|swift"
+cat package.json
+cat pyproject.toml
+cat Cargo.toml
+cat pubspec.yaml
+cat go.mod
+```
+
+## Parallel Review Passes
+
+If parallel subagents are available, split the review into these passes:
+
+1. Agents
+ - classify `agents/*`
+2. Skills
+ - classify `skills/*`
+3. Commands
+ - classify `commands/*`
+4. Rules
+ - classify `rules/*`
+5. Hooks and scripts
+ - classify hook surfaces, MCP health checks, helper scripts, and OS compatibility
+6. Extras
+ - classify contexts, examples, MCP configs, templates, and guidance docs
+
+If subagents are not available, run the same passes sequentially.
+
+## Core Workflow
+
+### 1. Read the repo
+
+Establish the real stack before classifying anything:
+
+- languages in use
+- frameworks in use
+- primary package manager
+- test stack
+- lint/format stack
+- deployment/runtime surface
+- operator integrations already present
+
+### 2. Build the evidence table
+
+For every candidate surface, record:
+
+- component path
+- component type
+- proposed bucket
+- repo evidence
+- short justification
+
+Use this format:
+
+```text
+skills/frontend-patterns | skill | DAILY | 84 .tsx files, next.config.ts present | core frontend stack
+skills/django-patterns | skill | LIBRARY | no .py files, no pyproject.toml | not active in this repo
+rules/typescript/* | rules | DAILY | package.json + tsconfig.json | active TS repo
+rules/python/* | rules | LIBRARY | zero Python source files | keep accessible only
+```
+
+### 3. Decide DAILY vs LIBRARY
+
+Promote to `DAILY` when:
+
+- the repo clearly uses the matching stack
+- the component is general enough to help every session
+- the repo already depends on the corresponding runtime or workflow
+
+Demote to `LIBRARY` when:
+
+- the component is off-stack
+- the repo might need it later, but not every day
+- it adds context overhead without immediate relevance
+
+### 4. Build the install plan
+
+Translate the classification into action:
+
+- DAILY skills -> install or keep in `.claude/skills/`
+- DAILY commands -> keep as explicit shims only if still useful
+- DAILY rules -> install only matching language sets
+- DAILY hooks/scripts -> keep only compatible ones
+- LIBRARY surfaces -> keep accessible through search or `skill-library`
+
+If the repo already uses selective installs, update that plan instead of creating another system.
+
+### 5. Create the optional library router
+
+If the project wants a searchable library surface, create:
+
+- `.claude/skills/skill-library/SKILL.md`
+
+That router should contain:
+
+- a short explanation of DAILY vs LIBRARY
+- grouped trigger keywords
+- where the library references live
+
+Do not duplicate every skill body inside the router.
+
+### 6. Verify the result
+
+After the plan is applied, verify:
+
+- every DAILY file exists where expected
+- stale language rules were not left active
+- incompatible hooks were not installed
+- the resulting install actually matches the repo stack
+
+Return a compact report with:
+
+- DAILY count
+- LIBRARY count
+- removed stale surfaces
+- open questions
+
+## Handoffs
+
+If the next step is interactive installation or repair, hand off to:
+
+- `configure-ecc`
+
+If the next step is overlap cleanup or catalog review, hand off to:
+
+- `skill-stocktake`
+
+If the next step is broader context trimming, hand off to:
+
+- `strategic-compact`
+
+## Output Format
+
+Return the result in this order:
+
+```text
+STACK
+- language/framework/runtime summary
+
+DAILY
+- always-loaded items with evidence
+
+LIBRARY
+- searchable/reference items with evidence
+
+INSTALL PLAN
+- what should be installed, removed, or routed
+
+VERIFICATION
+- checks run and remaining gaps
+```
diff --git a/vendor/everything-claude-code/agents-skills/agent-sort/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/agent-sort/agents/openai.yaml
new file mode 100644
index 000000000..85832bc20
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/agent-sort/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Agent Sort"
+ short_description: "Evidence-backed ECC install planning"
+ brand_color: "#0EA5E9"
+ default_prompt: "Use $agent-sort to build an evidence-backed ECC install plan."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/api-design/SKILL.md b/vendor/everything-claude-code/agents-skills/api-design/SKILL.md
new file mode 100644
index 000000000..4a9aa4176
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/api-design/SKILL.md
@@ -0,0 +1,522 @@
+---
+name: api-design
+description: REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs.
+---
+
+# API Design Patterns
+
+Conventions and best practices for designing consistent, developer-friendly REST APIs.
+
+## When to Activate
+
+- Designing new API endpoints
+- Reviewing existing API contracts
+- Adding pagination, filtering, or sorting
+- Implementing error handling for APIs
+- Planning API versioning strategy
+- Building public or partner-facing APIs
+
+## Resource Design
+
+### URL Structure
+
+```
+# Resources are nouns, plural, lowercase, kebab-case
+GET /api/v1/users
+GET /api/v1/users/:id
+POST /api/v1/users
+PUT /api/v1/users/:id
+PATCH /api/v1/users/:id
+DELETE /api/v1/users/:id
+
+# Sub-resources for relationships
+GET /api/v1/users/:id/orders
+POST /api/v1/users/:id/orders
+
+# Actions that don't map to CRUD (use verbs sparingly)
+POST /api/v1/orders/:id/cancel
+POST /api/v1/auth/login
+POST /api/v1/auth/refresh
+```
+
+### Naming Rules
+
+```
+# GOOD
+/api/v1/team-members # kebab-case for multi-word resources
+/api/v1/orders?status=active # query params for filtering
+/api/v1/users/123/orders # nested resources for ownership
+
+# BAD
+/api/v1/getUsers # verb in URL
+/api/v1/user # singular (use plural)
+/api/v1/team_members # snake_case in URLs
+/api/v1/users/123/getOrders # verb in nested resource
+```
+
+## HTTP Methods and Status Codes
+
+### Method Semantics
+
+| Method | Idempotent | Safe | Use For |
+|--------|-----------|------|---------|
+| GET | Yes | Yes | Retrieve resources |
+| POST | No | No | Create resources, trigger actions |
+| PUT | Yes | No | Full replacement of a resource |
+| PATCH | No* | No | Partial update of a resource |
+| DELETE | Yes | No | Remove a resource |
+
+*PATCH can be made idempotent with proper implementation
+
+### Status Code Reference
+
+```
+# Success
+200 OK — GET, PUT, PATCH (with response body)
+201 Created — POST (include Location header)
+204 No Content — DELETE, PUT (no response body)
+
+# Client Errors
+400 Bad Request — Validation failure, malformed JSON
+401 Unauthorized — Missing or invalid authentication
+403 Forbidden — Authenticated but not authorized
+404 Not Found — Resource doesn't exist
+409 Conflict — Duplicate entry, state conflict
+422 Unprocessable Entity — Semantically invalid (valid JSON, bad data)
+429 Too Many Requests — Rate limit exceeded
+
+# Server Errors
+500 Internal Server Error — Unexpected failure (never expose details)
+502 Bad Gateway — Upstream service failed
+503 Service Unavailable — Temporary overload, include Retry-After
+```
+
+### Common Mistakes
+
+```
+# BAD: 200 for everything
+{ "status": 200, "success": false, "error": "Not found" }
+
+# GOOD: Use HTTP status codes semantically
+HTTP/1.1 404 Not Found
+{ "error": { "code": "not_found", "message": "User not found" } }
+
+# BAD: 500 for validation errors
+# GOOD: 400 or 422 with field-level details
+
+# BAD: 200 for created resources
+# GOOD: 201 with Location header
+HTTP/1.1 201 Created
+Location: /api/v1/users/abc-123
+```
+
+## Response Format
+
+### Success Response
+
+```json
+{
+ "data": {
+ "id": "abc-123",
+ "email": "alice@example.com",
+ "name": "Alice",
+ "created_at": "2025-01-15T10:30:00Z"
+ }
+}
+```
+
+### Collection Response (with Pagination)
+
+```json
+{
+ "data": [
+ { "id": "abc-123", "name": "Alice" },
+ { "id": "def-456", "name": "Bob" }
+ ],
+ "meta": {
+ "total": 142,
+ "page": 1,
+ "per_page": 20,
+ "total_pages": 8
+ },
+ "links": {
+ "self": "/api/v1/users?page=1&per_page=20",
+ "next": "/api/v1/users?page=2&per_page=20",
+ "last": "/api/v1/users?page=8&per_page=20"
+ }
+}
+```
+
+### Error Response
+
+```json
+{
+ "error": {
+ "code": "validation_error",
+ "message": "Request validation failed",
+ "details": [
+ {
+ "field": "email",
+ "message": "Must be a valid email address",
+ "code": "invalid_format"
+ },
+ {
+ "field": "age",
+ "message": "Must be between 0 and 150",
+ "code": "out_of_range"
+ }
+ ]
+ }
+}
+```
+
+### Response Envelope Variants
+
+```typescript
+// Option A: Envelope with data wrapper (recommended for public APIs)
+interface ApiResponse {
+ data: T;
+ meta?: PaginationMeta;
+ links?: PaginationLinks;
+}
+
+interface ApiError {
+ error: {
+ code: string;
+ message: string;
+ details?: FieldError[];
+ };
+}
+
+// Option B: Flat response (simpler, common for internal APIs)
+// Success: just return the resource directly
+// Error: return error object
+// Distinguish by HTTP status code
+```
+
+## Pagination
+
+### Offset-Based (Simple)
+
+```
+GET /api/v1/users?page=2&per_page=20
+
+# Implementation
+SELECT * FROM users
+ORDER BY created_at DESC
+LIMIT 20 OFFSET 20;
+```
+
+**Pros:** Easy to implement, supports "jump to page N"
+**Cons:** Slow on large offsets (OFFSET 100000), inconsistent with concurrent inserts
+
+### Cursor-Based (Scalable)
+
+```
+GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20
+
+# Implementation
+SELECT * FROM users
+WHERE id > :cursor_id
+ORDER BY id ASC
+LIMIT 21; -- fetch one extra to determine has_next
+```
+
+```json
+{
+ "data": [...],
+ "meta": {
+ "has_next": true,
+ "next_cursor": "eyJpZCI6MTQzfQ"
+ }
+}
+```
+
+**Pros:** Consistent performance regardless of position, stable with concurrent inserts
+**Cons:** Cannot jump to arbitrary page, cursor is opaque
+
+### When to Use Which
+
+| Use Case | Pagination Type |
+|----------|----------------|
+| Admin dashboards, small datasets (<10K) | Offset |
+| Infinite scroll, feeds, large datasets | Cursor |
+| Public APIs | Cursor (default) with offset (optional) |
+| Search results | Offset (users expect page numbers) |
+
+## Filtering, Sorting, and Search
+
+### Filtering
+
+```
+# Simple equality
+GET /api/v1/orders?status=active&customer_id=abc-123
+
+# Comparison operators (use bracket notation)
+GET /api/v1/products?price[gte]=10&price[lte]=100
+GET /api/v1/orders?created_at[after]=2025-01-01
+
+# Multiple values (comma-separated)
+GET /api/v1/products?category=electronics,clothing
+
+# Nested fields (dot notation)
+GET /api/v1/orders?customer.country=US
+```
+
+### Sorting
+
+```
+# Single field (prefix - for descending)
+GET /api/v1/products?sort=-created_at
+
+# Multiple fields (comma-separated)
+GET /api/v1/products?sort=-featured,price,-created_at
+```
+
+### Full-Text Search
+
+```
+# Search query parameter
+GET /api/v1/products?q=wireless+headphones
+
+# Field-specific search
+GET /api/v1/users?email=alice
+```
+
+### Sparse Fieldsets
+
+```
+# Return only specified fields (reduces payload)
+GET /api/v1/users?fields=id,name,email
+GET /api/v1/orders?fields=id,total,status&include=customer.name
+```
+
+## Authentication and Authorization
+
+### Token-Based Auth
+
+```
+# Bearer token in Authorization header
+GET /api/v1/users
+Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
+
+# API key (for server-to-server)
+GET /api/v1/data
+X-API-Key: sk_live_abc123
+```
+
+### Authorization Patterns
+
+```typescript
+// Resource-level: check ownership
+app.get("/api/v1/orders/:id", async (req, res) => {
+ const order = await Order.findById(req.params.id);
+ if (!order) return res.status(404).json({ error: { code: "not_found" } });
+ if (order.userId !== req.user.id) return res.status(403).json({ error: { code: "forbidden" } });
+ return res.json({ data: order });
+});
+
+// Role-based: check permissions
+app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => {
+ await User.delete(req.params.id);
+ return res.status(204).send();
+});
+```
+
+## Rate Limiting
+
+### Headers
+
+```
+HTTP/1.1 200 OK
+X-RateLimit-Limit: 100
+X-RateLimit-Remaining: 95
+X-RateLimit-Reset: 1640000000
+
+# When exceeded
+HTTP/1.1 429 Too Many Requests
+Retry-After: 60
+{
+ "error": {
+ "code": "rate_limit_exceeded",
+ "message": "Rate limit exceeded. Try again in 60 seconds."
+ }
+}
+```
+
+### Rate Limit Tiers
+
+| Tier | Limit | Window | Use Case |
+|------|-------|--------|----------|
+| Anonymous | 30/min | Per IP | Public endpoints |
+| Authenticated | 100/min | Per user | Standard API access |
+| Premium | 1000/min | Per API key | Paid API plans |
+| Internal | 10000/min | Per service | Service-to-service |
+
+## Versioning
+
+### URL Path Versioning (Recommended)
+
+```
+/api/v1/users
+/api/v2/users
+```
+
+**Pros:** Explicit, easy to route, cacheable
+**Cons:** URL changes between versions
+
+### Header Versioning
+
+```
+GET /api/users
+Accept: application/vnd.myapp.v2+json
+```
+
+**Pros:** Clean URLs
+**Cons:** Harder to test, easy to forget
+
+### Versioning Strategy
+
+```
+1. Start with /api/v1/ — don't version until you need to
+2. Maintain at most 2 active versions (current + previous)
+3. Deprecation timeline:
+ - Announce deprecation (6 months notice for public APIs)
+ - Add Sunset header: Sunset: Sat, 01 Jan 2026 00:00:00 GMT
+ - Return 410 Gone after sunset date
+4. Non-breaking changes don't need a new version:
+ - Adding new fields to responses
+ - Adding new optional query parameters
+ - Adding new endpoints
+5. Breaking changes require a new version:
+ - Removing or renaming fields
+ - Changing field types
+ - Changing URL structure
+ - Changing authentication method
+```
+
+## Implementation Patterns
+
+### TypeScript (Next.js API Route)
+
+```typescript
+import { z } from "zod";
+import { NextRequest, NextResponse } from "next/server";
+
+const createUserSchema = z.object({
+ email: z.string().email(),
+ name: z.string().min(1).max(100),
+});
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const parsed = createUserSchema.safeParse(body);
+
+ if (!parsed.success) {
+ return NextResponse.json({
+ error: {
+ code: "validation_error",
+ message: "Request validation failed",
+ details: parsed.error.issues.map(i => ({
+ field: i.path.join("."),
+ message: i.message,
+ code: i.code,
+ })),
+ },
+ }, { status: 422 });
+ }
+
+ const user = await createUser(parsed.data);
+
+ return NextResponse.json(
+ { data: user },
+ {
+ status: 201,
+ headers: { Location: `/api/v1/users/${user.id}` },
+ },
+ );
+}
+```
+
+### Python (Django REST Framework)
+
+```python
+from rest_framework import serializers, viewsets, status
+from rest_framework.response import Response
+
+class CreateUserSerializer(serializers.Serializer):
+ email = serializers.EmailField()
+ name = serializers.CharField(max_length=100)
+
+class UserSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = User
+ fields = ["id", "email", "name", "created_at"]
+
+class UserViewSet(viewsets.ModelViewSet):
+ serializer_class = UserSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == "create":
+ return CreateUserSerializer
+ return UserSerializer
+
+ def create(self, request):
+ serializer = CreateUserSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ user = UserService.create(**serializer.validated_data)
+ return Response(
+ {"data": UserSerializer(user).data},
+ status=status.HTTP_201_CREATED,
+ headers={"Location": f"/api/v1/users/{user.id}"},
+ )
+```
+
+### Go (net/http)
+
+```go
+func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
+ var req CreateUserRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
+ return
+ }
+
+ if err := req.Validate(); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "validation_error", err.Error())
+ return
+ }
+
+ user, err := h.service.Create(r.Context(), req)
+ if err != nil {
+ switch {
+ case errors.Is(err, domain.ErrEmailTaken):
+ writeError(w, http.StatusConflict, "email_taken", "Email already registered")
+ default:
+ writeError(w, http.StatusInternalServerError, "internal_error", "Internal error")
+ }
+ return
+ }
+
+ w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID))
+ writeJSON(w, http.StatusCreated, map[string]any{"data": user})
+}
+```
+
+## API Design Checklist
+
+Before shipping a new endpoint:
+
+- [ ] Resource URL follows naming conventions (plural, kebab-case, no verbs)
+- [ ] Correct HTTP method used (GET for reads, POST for creates, etc.)
+- [ ] Appropriate status codes returned (not 200 for everything)
+- [ ] Input validated with schema (Zod, Pydantic, Bean Validation)
+- [ ] Error responses follow standard format with codes and messages
+- [ ] Pagination implemented for list endpoints (cursor or offset)
+- [ ] Authentication required (or explicitly marked as public)
+- [ ] Authorization checked (user can only access their own resources)
+- [ ] Rate limiting configured
+- [ ] Response does not leak internal details (stack traces, SQL errors)
+- [ ] Consistent naming with existing endpoints (camelCase vs snake_case)
+- [ ] Documented (OpenAPI/Swagger spec updated)
diff --git a/vendor/everything-claude-code/agents-skills/api-design/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/api-design/agents/openai.yaml
new file mode 100644
index 000000000..9daa40121
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/api-design/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "API Design"
+ short_description: "REST API design patterns and best practices"
+ brand_color: "#F97316"
+ default_prompt: "Use $api-design to design production REST API resources and responses."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/article-writing/SKILL.md b/vendor/everything-claude-code/agents-skills/article-writing/SKILL.md
new file mode 100644
index 000000000..2f17b3e67
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/article-writing/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: article-writing
+description: Write articles, guides, blog posts, tutorials, newsletter issues, and other long-form content in a distinctive voice derived from supplied examples or brand guidance. Use when the user wants polished written content longer than a paragraph, especially when voice consistency, structure, and credibility matter.
+---
+
+# Article Writing
+
+Write long-form content that sounds like an actual person with a point of view, not an LLM smoothing itself into paste.
+
+## When to Activate
+
+- drafting blog posts, essays, launch posts, guides, tutorials, or newsletter issues
+- turning notes, transcripts, or research into polished articles
+- matching an existing founder, operator, or brand voice from examples
+- tightening structure, pacing, and evidence in already-written long-form copy
+
+## Core Rules
+
+1. Lead with the concrete thing: artifact, example, output, anecdote, number, screenshot, or code.
+2. Explain after the example, not before.
+3. Keep sentences tight unless the source voice is intentionally expansive.
+4. Use proof instead of adjectives.
+5. Never invent facts, credibility, or customer evidence.
+
+## Voice Handling
+
+If the user wants a specific voice, run `brand-voice` first and reuse its `VOICE PROFILE`.
+Do not duplicate a second style-analysis pass here unless the user explicitly asks for one.
+
+If no voice references are given, default to a sharp operator voice: concrete, unsentimental, useful.
+
+## Banned Patterns
+
+Delete and rewrite any of these:
+- "In today's rapidly evolving landscape"
+- "game-changer", "cutting-edge", "revolutionary"
+- "here's why this matters" as a standalone bridge
+- fake vulnerability arcs
+- a closing question added only to juice engagement
+- biography padding that does not move the argument
+- generic AI throat-clearing that delays the point
+
+## Writing Process
+
+1. Clarify the audience and purpose.
+2. Build a hard outline with one job per section.
+3. Start sections with proof, artifact, conflict, or example.
+4. Expand only where the next sentence earns space.
+5. Cut anything that sounds templated, overexplained, or self-congratulatory.
+
+## Structure Guidance
+
+### Technical Guides
+
+- open with what the reader gets
+- use code, commands, screenshots, or concrete output in major sections
+- end with actionable takeaways, not a soft recap
+
+### Essays / Opinion
+
+- start with tension, contradiction, or a specific observation
+- keep one argument thread per section
+- make opinions answer to evidence
+
+### Newsletters
+
+- keep the first screen doing real work
+- do not front-load diary filler
+- use section labels only when they improve scanability
+
+## Quality Gate
+
+Before delivering:
+- factual claims are backed by provided sources
+- generic AI transitions are gone
+- the voice matches the supplied examples or the agreed `VOICE PROFILE`
+- every section adds something new
+- formatting matches the intended medium
diff --git a/vendor/everything-claude-code/agents-skills/article-writing/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/article-writing/agents/openai.yaml
new file mode 100644
index 000000000..14dfe51ea
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/article-writing/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Article Writing"
+ short_description: "Long-form content in a supplied voice"
+ brand_color: "#B45309"
+ default_prompt: "Use $article-writing to draft polished long-form content in the supplied voice."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/backend-patterns/SKILL.md b/vendor/everything-claude-code/agents-skills/backend-patterns/SKILL.md
new file mode 100644
index 000000000..aa049462c
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/backend-patterns/SKILL.md
@@ -0,0 +1,597 @@
+---
+name: backend-patterns
+description: Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes.
+---
+
+# Backend Development Patterns
+
+Backend architecture patterns and best practices for scalable server-side applications.
+
+## When to Activate
+
+- Designing REST or GraphQL API endpoints
+- Implementing repository, service, or controller layers
+- Optimizing database queries (N+1, indexing, connection pooling)
+- Adding caching (Redis, in-memory, HTTP cache headers)
+- Setting up background jobs or async processing
+- Structuring error handling and validation for APIs
+- Building middleware (auth, logging, rate limiting)
+
+## API Design Patterns
+
+### RESTful API Structure
+
+```typescript
+// PASS: Resource-based URLs
+GET /api/markets # List resources
+GET /api/markets/:id # Get single resource
+POST /api/markets # Create resource
+PUT /api/markets/:id # Replace resource
+PATCH /api/markets/:id # Update resource
+DELETE /api/markets/:id # Delete resource
+
+// PASS: Query parameters for filtering, sorting, pagination
+GET /api/markets?status=active&sort=volume&limit=20&offset=0
+```
+
+### Repository Pattern
+
+```typescript
+// Abstract data access logic
+interface MarketRepository {
+ findAll(filters?: MarketFilters): Promise
+ findById(id: string): Promise
+ create(data: CreateMarketDto): Promise
+ update(id: string, data: UpdateMarketDto): Promise
+ delete(id: string): Promise
+}
+
+class SupabaseMarketRepository implements MarketRepository {
+ async findAll(filters?: MarketFilters): Promise {
+ let query = supabase.from('markets').select('*')
+
+ if (filters?.status) {
+ query = query.eq('status', filters.status)
+ }
+
+ if (filters?.limit) {
+ query = query.limit(filters.limit)
+ }
+
+ const { data, error } = await query
+
+ if (error) throw new Error(error.message)
+ return data
+ }
+
+ // Other methods...
+}
+```
+
+### Service Layer Pattern
+
+```typescript
+// Business logic separated from data access
+class MarketService {
+ constructor(private marketRepo: MarketRepository) {}
+
+ async searchMarkets(query: string, limit: number = 10): Promise {
+ // Business logic
+ const embedding = await generateEmbedding(query)
+ const results = await this.vectorSearch(embedding, limit)
+
+ // Fetch full data
+ const markets = await this.marketRepo.findByIds(results.map(r => r.id))
+
+ // Sort by similarity
+ return markets.sort((a, b) => {
+ const scoreA = results.find(r => r.id === a.id)?.score || 0
+ const scoreB = results.find(r => r.id === b.id)?.score || 0
+ return scoreA - scoreB
+ })
+ }
+
+ private async vectorSearch(embedding: number[], limit: number) {
+ // Vector search implementation
+ }
+}
+```
+
+### Middleware Pattern
+
+```typescript
+// Request/response processing pipeline
+export function withAuth(handler: NextApiHandler): NextApiHandler {
+ return async (req, res) => {
+ const token = req.headers.authorization?.replace('Bearer ', '')
+
+ if (!token) {
+ return res.status(401).json({ error: 'Unauthorized' })
+ }
+
+ try {
+ const user = await verifyToken(token)
+ req.user = user
+ return handler(req, res)
+ } catch (error) {
+ return res.status(401).json({ error: 'Invalid token' })
+ }
+ }
+}
+
+// Usage
+export default withAuth(async (req, res) => {
+ // Handler has access to req.user
+})
+```
+
+## Database Patterns
+
+### Query Optimization
+
+```typescript
+// PASS: GOOD: Select only needed columns
+const { data } = await supabase
+ .from('markets')
+ .select('id, name, status, volume')
+ .eq('status', 'active')
+ .order('volume', { ascending: false })
+ .limit(10)
+
+// FAIL: BAD: Select everything
+const { data } = await supabase
+ .from('markets')
+ .select('*')
+```
+
+### N+1 Query Prevention
+
+```typescript
+// FAIL: BAD: N+1 query problem
+const markets = await getMarkets()
+for (const market of markets) {
+ market.creator = await getUser(market.creator_id) // N queries
+}
+
+// PASS: GOOD: Batch fetch
+const markets = await getMarkets()
+const creatorIds = markets.map(m => m.creator_id)
+const creators = await getUsers(creatorIds) // 1 query
+const creatorMap = new Map(creators.map(c => [c.id, c]))
+
+markets.forEach(market => {
+ market.creator = creatorMap.get(market.creator_id)
+})
+```
+
+### Transaction Pattern
+
+```typescript
+async function createMarketWithPosition(
+ marketData: CreateMarketDto,
+ positionData: CreatePositionDto
+) {
+ // Use Supabase transaction
+ const { data, error } = await supabase.rpc('create_market_with_position', {
+ market_data: marketData,
+ position_data: positionData
+ })
+
+ if (error) throw new Error('Transaction failed')
+ return data
+}
+
+// SQL function in Supabase
+CREATE OR REPLACE FUNCTION create_market_with_position(
+ market_data jsonb,
+ position_data jsonb
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ -- Start transaction automatically
+ INSERT INTO markets VALUES (market_data);
+ INSERT INTO positions VALUES (position_data);
+ RETURN jsonb_build_object('success', true);
+EXCEPTION
+ WHEN OTHERS THEN
+ -- Rollback happens automatically
+ RETURN jsonb_build_object('success', false, 'error', SQLERRM);
+END;
+$$;
+```
+
+## Caching Strategies
+
+### Redis Caching Layer
+
+```typescript
+class CachedMarketRepository implements MarketRepository {
+ constructor(
+ private baseRepo: MarketRepository,
+ private redis: RedisClient
+ ) {}
+
+ async findById(id: string): Promise {
+ // Check cache first
+ const cached = await this.redis.get(`market:${id}`)
+
+ if (cached) {
+ return JSON.parse(cached)
+ }
+
+ // Cache miss - fetch from database
+ const market = await this.baseRepo.findById(id)
+
+ if (market) {
+ // Cache for 5 minutes
+ await this.redis.setex(`market:${id}`, 300, JSON.stringify(market))
+ }
+
+ return market
+ }
+
+ async invalidateCache(id: string): Promise {
+ await this.redis.del(`market:${id}`)
+ }
+}
+```
+
+### Cache-Aside Pattern
+
+```typescript
+async function getMarketWithCache(id: string): Promise {
+ const cacheKey = `market:${id}`
+
+ // Try cache
+ const cached = await redis.get(cacheKey)
+ if (cached) return JSON.parse(cached)
+
+ // Cache miss - fetch from DB
+ const market = await db.markets.findUnique({ where: { id } })
+
+ if (!market) throw new Error('Market not found')
+
+ // Update cache
+ await redis.setex(cacheKey, 300, JSON.stringify(market))
+
+ return market
+}
+```
+
+## Error Handling Patterns
+
+### Centralized Error Handler
+
+```typescript
+class ApiError extends Error {
+ constructor(
+ public statusCode: number,
+ public message: string,
+ public isOperational = true
+ ) {
+ super(message)
+ Object.setPrototypeOf(this, ApiError.prototype)
+ }
+}
+
+export function errorHandler(error: unknown, req: Request): Response {
+ if (error instanceof ApiError) {
+ return NextResponse.json({
+ success: false,
+ error: error.message
+ }, { status: error.statusCode })
+ }
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({
+ success: false,
+ error: 'Validation failed',
+ details: error.errors
+ }, { status: 400 })
+ }
+
+ // Log unexpected errors
+ console.error('Unexpected error:', error)
+
+ return NextResponse.json({
+ success: false,
+ error: 'Internal server error'
+ }, { status: 500 })
+}
+
+// Usage
+export async function GET(request: Request) {
+ try {
+ const data = await fetchData()
+ return NextResponse.json({ success: true, data })
+ } catch (error) {
+ return errorHandler(error, request)
+ }
+}
+```
+
+### Retry with Exponential Backoff
+
+```typescript
+async function fetchWithRetry(
+ fn: () => Promise,
+ maxRetries = 3
+): Promise {
+ let lastError: Error
+
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ return await fn()
+ } catch (error) {
+ lastError = error as Error
+
+ if (i < maxRetries - 1) {
+ // Exponential backoff: 1s, 2s, 4s
+ const delay = Math.pow(2, i) * 1000
+ await new Promise(resolve => setTimeout(resolve, delay))
+ }
+ }
+ }
+
+ throw lastError!
+}
+
+// Usage
+const data = await fetchWithRetry(() => fetchFromAPI())
+```
+
+## Authentication & Authorization
+
+### JWT Token Validation
+
+```typescript
+import jwt from 'jsonwebtoken'
+
+interface JWTPayload {
+ userId: string
+ email: string
+ role: 'admin' | 'user'
+}
+
+export function verifyToken(token: string): JWTPayload {
+ try {
+ const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload
+ return payload
+ } catch (error) {
+ throw new ApiError(401, 'Invalid token')
+ }
+}
+
+export async function requireAuth(request: Request) {
+ const token = request.headers.get('authorization')?.replace('Bearer ', '')
+
+ if (!token) {
+ throw new ApiError(401, 'Missing authorization token')
+ }
+
+ return verifyToken(token)
+}
+
+// Usage in API route
+export async function GET(request: Request) {
+ const user = await requireAuth(request)
+
+ const data = await getDataForUser(user.userId)
+
+ return NextResponse.json({ success: true, data })
+}
+```
+
+### Role-Based Access Control
+
+```typescript
+type Permission = 'read' | 'write' | 'delete' | 'admin'
+
+interface User {
+ id: string
+ role: 'admin' | 'moderator' | 'user'
+}
+
+const rolePermissions: Record = {
+ admin: ['read', 'write', 'delete', 'admin'],
+ moderator: ['read', 'write', 'delete'],
+ user: ['read', 'write']
+}
+
+export function hasPermission(user: User, permission: Permission): boolean {
+ return rolePermissions[user.role].includes(permission)
+}
+
+export function requirePermission(permission: Permission) {
+ return (handler: (request: Request, user: User) => Promise) => {
+ return async (request: Request) => {
+ const user = await requireAuth(request)
+
+ if (!hasPermission(user, permission)) {
+ throw new ApiError(403, 'Insufficient permissions')
+ }
+
+ return handler(request, user)
+ }
+ }
+}
+
+// Usage - HOF wraps the handler
+export const DELETE = requirePermission('delete')(
+ async (request: Request, user: User) => {
+ // Handler receives authenticated user with verified permission
+ return new Response('Deleted', { status: 200 })
+ }
+)
+```
+
+## Rate Limiting
+
+### Simple In-Memory Rate Limiter
+
+```typescript
+class RateLimiter {
+ private requests = new Map()
+
+ async checkLimit(
+ identifier: string,
+ maxRequests: number,
+ windowMs: number
+ ): Promise {
+ const now = Date.now()
+ const requests = this.requests.get(identifier) || []
+
+ // Remove old requests outside window
+ const recentRequests = requests.filter(time => now - time < windowMs)
+
+ if (recentRequests.length >= maxRequests) {
+ return false // Rate limit exceeded
+ }
+
+ // Add current request
+ recentRequests.push(now)
+ this.requests.set(identifier, recentRequests)
+
+ return true
+ }
+}
+
+const limiter = new RateLimiter()
+
+export async function GET(request: Request) {
+ const ip = request.headers.get('x-forwarded-for') || 'unknown'
+
+ const allowed = await limiter.checkLimit(ip, 100, 60000) // 100 req/min
+
+ if (!allowed) {
+ return NextResponse.json({
+ error: 'Rate limit exceeded'
+ }, { status: 429 })
+ }
+
+ // Continue with request
+}
+```
+
+## Background Jobs & Queues
+
+### Simple Queue Pattern
+
+```typescript
+class JobQueue {
+ private queue: T[] = []
+ private processing = false
+
+ async add(job: T): Promise {
+ this.queue.push(job)
+
+ if (!this.processing) {
+ this.process()
+ }
+ }
+
+ private async process(): Promise {
+ this.processing = true
+
+ while (this.queue.length > 0) {
+ const job = this.queue.shift()!
+
+ try {
+ await this.execute(job)
+ } catch (error) {
+ console.error('Job failed:', error)
+ }
+ }
+
+ this.processing = false
+ }
+
+ private async execute(job: T): Promise {
+ // Job execution logic
+ }
+}
+
+// Usage for indexing markets
+interface IndexJob {
+ marketId: string
+}
+
+const indexQueue = new JobQueue()
+
+export async function POST(request: Request) {
+ const { marketId } = await request.json()
+
+ // Add to queue instead of blocking
+ await indexQueue.add({ marketId })
+
+ return NextResponse.json({ success: true, message: 'Job queued' })
+}
+```
+
+## Logging & Monitoring
+
+### Structured Logging
+
+```typescript
+interface LogContext {
+ userId?: string
+ requestId?: string
+ method?: string
+ path?: string
+ [key: string]: unknown
+}
+
+class Logger {
+ log(level: 'info' | 'warn' | 'error', message: string, context?: LogContext) {
+ const entry = {
+ timestamp: new Date().toISOString(),
+ level,
+ message,
+ ...context
+ }
+
+ console.log(JSON.stringify(entry))
+ }
+
+ info(message: string, context?: LogContext) {
+ this.log('info', message, context)
+ }
+
+ warn(message: string, context?: LogContext) {
+ this.log('warn', message, context)
+ }
+
+ error(message: string, error: Error, context?: LogContext) {
+ this.log('error', message, {
+ ...context,
+ error: error.message,
+ stack: error.stack
+ })
+ }
+}
+
+const logger = new Logger()
+
+// Usage
+export async function GET(request: Request) {
+ const requestId = crypto.randomUUID()
+
+ logger.info('Fetching markets', {
+ requestId,
+ method: 'GET',
+ path: '/api/markets'
+ })
+
+ try {
+ const markets = await fetchMarkets()
+ return NextResponse.json({ success: true, data: markets })
+ } catch (error) {
+ logger.error('Failed to fetch markets', error as Error, { requestId })
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
+ }
+}
+```
+
+**Remember**: Backend patterns enable scalable, maintainable server-side applications. Choose patterns that fit your complexity level.
diff --git a/vendor/everything-claude-code/agents-skills/backend-patterns/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/backend-patterns/agents/openai.yaml
new file mode 100644
index 000000000..9ef955677
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/backend-patterns/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Backend Patterns"
+ short_description: "API, database, and server-side patterns"
+ brand_color: "#F59E0B"
+ default_prompt: "Use $backend-patterns to apply backend architecture and API patterns."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/brand-voice/SKILL.md b/vendor/everything-claude-code/agents-skills/brand-voice/SKILL.md
new file mode 100644
index 000000000..0ade4fc0d
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/brand-voice/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: brand-voice
+description: Build a source-derived writing style profile from real posts, essays, launch notes, docs, or site copy, then reuse that profile across content, outreach, and social workflows. Use when the user wants voice consistency without generic AI writing tropes.
+---
+
+# Brand Voice
+
+Build a durable voice profile from real source material, then use that profile everywhere instead of re-deriving style from scratch or defaulting to generic AI copy.
+
+## When to Activate
+
+- the user wants content or outreach in a specific voice
+- writing for X, LinkedIn, email, launch posts, threads, or product updates
+- adapting a known author's tone across channels
+- the existing content lane needs a reusable style system instead of one-off mimicry
+
+## Source Priority
+
+Use the strongest real source set available, in this order:
+
+1. recent original X posts and threads
+2. articles, essays, memos, launch notes, or newsletters
+3. real outbound emails or DMs that worked
+4. product docs, changelogs, README framing, and site copy
+
+Do not use generic platform exemplars as source material.
+
+## Collection Workflow
+
+1. Gather 5 to 20 representative samples when available.
+2. Prefer recent material over old material unless the user says the older writing is more canonical.
+3. Separate "public launch voice" from "private working voice" if the source set clearly splits.
+4. If live X access is available, use `x-api` to pull recent original posts before drafting.
+5. If site copy matters, include the current ECC landing page and repo/plugin framing.
+
+## What to Extract
+
+- rhythm and sentence length
+- compression vs explanation
+- capitalization norms
+- parenthetical use
+- question frequency and purpose
+- how sharply claims are made
+- how often numbers, mechanisms, or receipts show up
+- how transitions work
+- what the author never does
+
+## Output Contract
+
+Produce a reusable `VOICE PROFILE` block that downstream skills can consume directly. Use the schema in [references/voice-profile-schema.md](references/voice-profile-schema.md).
+
+Keep the profile structured and short enough to reuse in session context. The point is not literary criticism. The point is operational reuse.
+
+## Affaan / ECC Defaults
+
+If the user wants Affaan / ECC voice and live sources are thin, start here unless newer source material overrides it:
+
+- direct, compressed, concrete
+- specifics, mechanisms, receipts, and numbers beat adjectives
+- parentheticals are for qualification, narrowing, or over-clarification
+- capitalization is conventional unless there is a real reason to break it
+- questions are rare and should not be used as bait
+- tone can be sharp, blunt, skeptical, or dry
+- transitions should feel earned, not smoothed over
+
+## Hard Bans
+
+Delete and rewrite any of these:
+
+- fake curiosity hooks
+- "not X, just Y"
+- "no fluff"
+- forced lowercase
+- LinkedIn thought-leader cadence
+- bait questions
+- "Excited to share"
+- generic founder-journey filler
+- corny parentheticals
+
+## Persistence Rules
+
+- Reuse the latest confirmed `VOICE PROFILE` across related tasks in the same session.
+- If the user asks for a durable artifact, save the profile in the requested workspace location or memory surface.
+- Do not create repo-tracked files that store personal voice fingerprints unless the user explicitly asks for that.
+
+## Downstream Use
+
+Use this skill before or inside:
+
+- `content-engine`
+- `crosspost`
+- `lead-intelligence`
+- article or launch writing
+- cold or warm outbound across X, LinkedIn, and email
+
+If another skill already has a partial voice capture section, this skill is the canonical source of truth.
diff --git a/vendor/everything-claude-code/agents-skills/brand-voice/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/brand-voice/agents/openai.yaml
new file mode 100644
index 000000000..42a51a14f
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/brand-voice/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Brand Voice"
+ short_description: "Source-derived writing style profiles"
+ brand_color: "#0EA5E9"
+ default_prompt: "Use $brand-voice to derive and reuse a source-grounded writing style."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/brand-voice/references/voice-profile-schema.md b/vendor/everything-claude-code/agents-skills/brand-voice/references/voice-profile-schema.md
new file mode 100644
index 000000000..60b126630
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/brand-voice/references/voice-profile-schema.md
@@ -0,0 +1,55 @@
+# Voice Profile Schema
+
+Use this exact structure when building a reusable voice profile:
+
+```text
+VOICE PROFILE
+=============
+Author:
+Goal:
+Confidence:
+
+Source Set
+- source 1
+- source 2
+- source 3
+
+Rhythm
+- short note on sentence length, pacing, and fragmentation
+
+Compression
+- how dense or explanatory the writing is
+
+Capitalization
+- conventional, mixed, or situational
+
+Parentheticals
+- how they are used and how they are not used
+
+Question Use
+- rare, frequent, rhetorical, direct, or mostly absent
+
+Claim Style
+- how claims are framed, supported, and sharpened
+
+Preferred Moves
+- concrete moves the author does use
+
+Banned Moves
+- specific patterns the author does not use
+
+CTA Rules
+- how, when, or whether to close with asks
+
+Channel Notes
+- X:
+- LinkedIn:
+- Email:
+```
+
+Guidelines:
+
+- Keep the profile concrete and source-backed.
+- Use short bullets, not essay paragraphs.
+- Every banned move should be observable in the source set or explicitly requested by the user.
+- If the source set conflicts, call out the split instead of averaging it into mush.
diff --git a/vendor/everything-claude-code/agents-skills/bun-runtime/SKILL.md b/vendor/everything-claude-code/agents-skills/bun-runtime/SKILL.md
new file mode 100644
index 000000000..deb1f506c
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/bun-runtime/SKILL.md
@@ -0,0 +1,83 @@
+---
+name: bun-runtime
+description: Bun as runtime, package manager, bundler, and test runner. When to choose Bun vs Node, migration notes, and Vercel support.
+---
+
+# Bun Runtime
+
+Bun is a fast all-in-one JavaScript runtime and toolkit: runtime, package manager, bundler, and test runner.
+
+## When to Use
+
+- **Prefer Bun** for: new JS/TS projects, scripts where install/run speed matters, Vercel deployments with Bun runtime, and when you want a single toolchain (run + install + test + build).
+- **Prefer Node** for: maximum ecosystem compatibility, legacy tooling that assumes Node, or when a dependency has known Bun issues.
+
+Use when: adopting Bun, migrating from Node, writing or debugging Bun scripts/tests, or configuring Bun on Vercel or other platforms.
+
+## How It Works
+
+- **Runtime**: Drop-in Node-compatible runtime (built on JavaScriptCore, implemented in Zig).
+- **Package manager**: `bun install` is significantly faster than npm/yarn. Lockfile is `bun.lock` (text) by default in current Bun; older versions used `bun.lockb` (binary).
+- **Bundler**: Built-in bundler and transpiler for apps and libraries.
+- **Test runner**: Built-in `bun test` with Jest-like API.
+
+**Migration from Node**: Replace `node script.js` with `bun run script.js` or `bun script.js`. Run `bun install` in place of `npm install`; most packages work. Use `bun run` for npm scripts; `bun x` for npx-style one-off runs. Node built-ins are supported; prefer Bun APIs where they exist for better performance.
+
+**Vercel**: Set runtime to Bun in project settings. Build: `bun run build` or `bun build ./src/index.ts --outdir=dist`. Install: `bun install --frozen-lockfile` for reproducible deploys.
+
+## Examples
+
+### Run and install
+
+```bash
+# Install dependencies (creates/updates bun.lock or bun.lockb)
+bun install
+
+# Run a script or file
+bun run dev
+bun run src/index.ts
+bun src/index.ts
+```
+
+### Scripts and env
+
+```bash
+bun run --env-file=.env dev
+FOO=bar bun run script.ts
+```
+
+### Testing
+
+```bash
+bun test
+bun test --watch
+```
+
+```typescript
+// test/example.test.ts
+import { expect, test } from "bun:test";
+
+test("add", () => {
+ expect(1 + 2).toBe(3);
+});
+```
+
+### Runtime API
+
+```typescript
+const file = Bun.file("package.json");
+const json = await file.json();
+
+Bun.serve({
+ port: 3000,
+ fetch(req) {
+ return new Response("Hello");
+ },
+});
+```
+
+## Best Practices
+
+- Commit the lockfile (`bun.lock` or `bun.lockb`) for reproducible installs.
+- Prefer `bun run` for scripts. For TypeScript, Bun runs `.ts` natively.
+- Keep dependencies up to date; Bun and the ecosystem evolve quickly.
diff --git a/vendor/everything-claude-code/agents-skills/bun-runtime/agents/openai.yaml b/vendor/everything-claude-code/agents-skills/bun-runtime/agents/openai.yaml
new file mode 100644
index 000000000..6460a67d2
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/bun-runtime/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Bun Runtime"
+ short_description: "Bun runtime, package manager, and test runner"
+ brand_color: "#FBF0DF"
+ default_prompt: "Use $bun-runtime to choose and apply Bun runtime workflows."
+policy:
+ allow_implicit_invocation: true
diff --git a/vendor/everything-claude-code/agents-skills/coding-standards/SKILL.md b/vendor/everything-claude-code/agents-skills/coding-standards/SKILL.md
new file mode 100644
index 000000000..741136f69
--- /dev/null
+++ b/vendor/everything-claude-code/agents-skills/coding-standards/SKILL.md
@@ -0,0 +1,548 @@
+---
+name: coding-standards
+description: Baseline cross-project coding conventions for naming, readability, immutability, and code-quality review. Use detailed frontend or backend skills for framework-specific patterns.
+---
+
+# Coding Standards & Best Practices
+
+Baseline coding conventions applicable across projects.
+
+This skill is the shared floor, not the detailed framework playbook.
+
+- Use `frontend-patterns` for React, state, forms, rendering, and UI architecture.
+- Use `backend-patterns` or `api-design` for repository/service layers, endpoint design, validation, and server-specific concerns.
+- Use `rules/common/coding-style.md` when you need the shortest reusable rule layer instead of a full skill walkthrough.
+
+## When to Activate
+
+- Starting a new project or module
+- Reviewing code for quality and maintainability
+- Refactoring existing code to follow conventions
+- Enforcing naming, formatting, or structural consistency
+- Setting up linting, formatting, or type-checking rules
+- Onboarding new contributors to coding conventions
+
+## Scope Boundaries
+
+Activate this skill for:
+- descriptive naming
+- immutability defaults
+- readability, KISS, DRY, and YAGNI enforcement
+- error-handling expectations and code-smell review
+
+Do not use this skill as the primary source for:
+- React composition, hooks, or rendering patterns
+- backend architecture, API design, or database layering
+- domain-specific framework guidance when a narrower ECC skill already exists
+
+## Code Quality Principles
+
+### 1. Readability First
+- Code is read more than written
+- Clear variable and function names
+- Self-documenting code preferred over comments
+- Consistent formatting
+
+### 2. KISS (Keep It Simple, Stupid)
+- Simplest solution that works
+- Avoid over-engineering
+- No premature optimization
+- Easy to understand > clever code
+
+### 3. DRY (Don't Repeat Yourself)
+- Extract common logic into functions
+- Create reusable components
+- Share utilities across modules
+- Avoid copy-paste programming
+
+### 4. YAGNI (You Aren't Gonna Need It)
+- Don't build features before they're needed
+- Avoid speculative generality
+- Add complexity only when required
+- Start simple, refactor when needed
+
+## TypeScript/JavaScript Standards
+
+### Variable Naming
+
+```typescript
+// PASS: GOOD: Descriptive names
+const marketSearchQuery = 'election'
+const isUserAuthenticated = true
+const totalRevenue = 1000
+
+// FAIL: BAD: Unclear names
+const q = 'election'
+const flag = true
+const x = 1000
+```
+
+### Function Naming
+
+```typescript
+// PASS: GOOD: Verb-noun pattern
+async function fetchMarketData(marketId: string) { }
+function calculateSimilarity(a: number[], b: number[]) { }
+function isValidEmail(email: string): boolean { }
+
+// FAIL: BAD: Unclear or noun-only
+async function market(id: string) { }
+function similarity(a, b) { }
+function email(e) { }
+```
+
+### Immutability Pattern (CRITICAL)
+
+```typescript
+// PASS: ALWAYS use spread operator
+const updatedUser = {
+ ...user,
+ name: 'New Name'
+}
+
+const updatedArray = [...items, newItem]
+
+// FAIL: NEVER mutate directly
+user.name = 'New Name' // BAD
+items.push(newItem) // BAD
+```
+
+### Error Handling
+
+```typescript
+// PASS: GOOD: Comprehensive error handling
+async function fetchData(url: string) {
+ try {
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ console.error('Fetch failed:', error)
+ throw new Error('Failed to fetch data')
+ }
+}
+
+// FAIL: BAD: No error handling
+async function fetchData(url) {
+ const response = await fetch(url)
+ return response.json()
+}
+```
+
+### Async/Await Best Practices
+
+```typescript
+// PASS: GOOD: Parallel execution when possible
+const [users, markets, stats] = await Promise.all([
+ fetchUsers(),
+ fetchMarkets(),
+ fetchStats()
+])
+
+// FAIL: BAD: Sequential when unnecessary
+const users = await fetchUsers()
+const markets = await fetchMarkets()
+const stats = await fetchStats()
+```
+
+### Type Safety
+
+```typescript
+// PASS: GOOD: Proper types
+interface Market {
+ id: string
+ name: string
+ status: 'active' | 'resolved' | 'closed'
+ created_at: Date
+}
+
+function getMarket(id: string): Promise {
+ // Implementation
+}
+
+// FAIL: BAD: Using 'any'
+function getMarket(id: any): Promise {
+ // Implementation
+}
+```
+
+## React Best Practices
+
+### Component Structure
+
+```typescript
+// PASS: GOOD: Functional component with types
+interface ButtonProps {
+ children: React.ReactNode
+ onClick: () => void
+ disabled?: boolean
+ variant?: 'primary' | 'secondary'
+}
+
+export function Button({
+ children,
+ onClick,
+ disabled = false,
+ variant = 'primary'
+}: ButtonProps) {
+ return (
+
+ )
+}
+
+// FAIL: BAD: No types, unclear structure
+export function Button(props) {
+ return
+}
+```
+
+### Custom Hooks
+
+```typescript
+// PASS: GOOD: Reusable custom hook
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => clearTimeout(handler)
+ }, [value, delay])
+
+ return debouncedValue
+}
+
+// Usage
+const debouncedQuery = useDebounce(searchQuery, 500)
+```
+
+### State Management
+
+```typescript
+// PASS: GOOD: Proper state updates
+const [count, setCount] = useState(0)
+
+// Functional update for state based on previous state
+setCount(prev => prev + 1)
+
+// FAIL: BAD: Direct state reference
+setCount(count + 1) // Can be stale in async scenarios
+```
+
+### Conditional Rendering
+
+```typescript
+// PASS: GOOD: Clear conditional rendering
+{isLoading && }
+{error && }
+{data && }
+
+// FAIL: BAD: Ternary hell
+{isLoading ? : error ? : data ? : null}
+```
+
+## API Design Standards
+
+### REST API Conventions
+
+```
+GET /api/markets # List all markets
+GET /api/markets/:id # Get specific market
+POST /api/markets # Create new market
+PUT /api/markets/:id # Update market (full)
+PATCH /api/markets/:id # Update market (partial)
+DELETE /api/markets/:id # Delete market
+
+# Query parameters for filtering
+GET /api/markets?status=active&limit=10&offset=0
+```
+
+### Response Format
+
+```typescript
+// PASS: GOOD: Consistent response structure
+interface ApiResponse {
+ success: boolean
+ data?: T
+ error?: string
+ meta?: {
+ total: number
+ page: number
+ limit: number
+ }
+}
+
+// Success response
+return NextResponse.json({
+ success: true,
+ data: markets,
+ meta: { total: 100, page: 1, limit: 10 }
+})
+
+// Error response
+return NextResponse.json({
+ success: false,
+ error: 'Invalid request'
+}, { status: 400 })
+```
+
+### Input Validation
+
+```typescript
+import { z } from 'zod'
+
+// PASS: GOOD: Schema validation
+const CreateMarketSchema = z.object({
+ name: z.string().min(1).max(200),
+ description: z.string().min(1).max(2000),
+ endDate: z.string().datetime(),
+ categories: z.array(z.string()).min(1)
+})
+
+export async function POST(request: Request) {
+ const body = await request.json()
+
+ try {
+ const validated = CreateMarketSchema.parse(body)
+ // Proceed with validated data
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({
+ success: false,
+ error: 'Validation failed',
+ details: error.errors
+ }, { status: 400 })
+ }
+ }
+}
+```
+
+## File Organization
+
+### Project Structure
+
+```
+src/
+├── app/ # Next.js App Router
+│ ├── api/ # API routes
+│ ├── markets/ # Market pages
+│ └── (auth)/ # Auth pages (route groups)
+├── components/ # React components
+│ ├── ui/ # Generic UI components
+│ ├── forms/ # Form components
+│ └── layouts/ # Layout components
+├── hooks/ # Custom React hooks
+├── lib/ # Utilities and configs
+│ ├── api/ # API clients
+│ ├── utils/ # Helper functions
+│ └── constants/ # Constants
+├── types/ # TypeScript types
+└── styles/ # Global styles
+```
+
+### File Naming
+
+```
+components/Button.tsx # PascalCase for components
+hooks/useAuth.ts # camelCase with 'use' prefix
+lib/formatDate.ts # camelCase for utilities
+types/market.types.ts # camelCase with .types suffix
+```
+
+## Comments & Documentation
+
+### When to Comment
+
+```typescript
+// PASS: GOOD: Explain WHY, not WHAT
+// Use exponential backoff to avoid overwhelming the API during outages
+const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
+
+// Deliberately using mutation here for performance with large arrays
+items.push(newItem)
+
+// FAIL: BAD: Stating the obvious
+// Increment counter by 1
+count++
+
+// Set name to user's name
+name = user.name
+```
+
+### JSDoc for Public APIs
+
+```typescript
+/**
+ * Searches markets using semantic similarity.
+ *
+ * @param query - Natural language search query
+ * @param limit - Maximum number of results (default: 10)
+ * @returns Array of markets sorted by similarity score
+ * @throws {Error} If OpenAI API fails or Redis unavailable
+ *
+ * @example
+ * ```typescript
+ * const results = await searchMarkets('election', 5)
+ * console.log(results[0].name) // "Trump vs Biden"
+ * ```
+ */
+export async function searchMarkets(
+ query: string,
+ limit: number = 10
+): Promise {
+ // Implementation
+}
+```
+
+## Performance Best Practices
+
+### Memoization
+
+```typescript
+import { useMemo, useCallback } from 'react'
+
+// PASS: GOOD: Memoize expensive computations
+const sortedMarkets = useMemo(() => {
+ return markets.sort((a, b) => b.volume - a.volume)
+}, [markets])
+
+// PASS: GOOD: Memoize callbacks
+const handleSearch = useCallback((query: string) => {
+ setSearchQuery(query)
+}, [])
+```
+
+### Lazy Loading
+
+```typescript
+import { lazy, Suspense } from 'react'
+
+// PASS: GOOD: Lazy load heavy components
+const HeavyChart = lazy(() => import('./HeavyChart'))
+
+export function Dashboard() {
+ return (
+