Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,34 @@ FROM python:3.12-slim

WORKDIR /app/OpenManus

RUN apt-get update && apt-get install -y --no-install-recommends git curl \
# Install system deps including Playwright browser requirements
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl wget gnupg ca-certificates \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libdbus-1-3 libexpat1 libxcb1 libxkbcommon0 libx11-6 \
libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \
&& rm -rf /var/lib/apt/lists/* \
&& (command -v uv >/dev/null 2>&1 || pip install --no-cache-dir uv)

COPY . .

# Install Python dependencies
RUN uv pip install --system -r requirements.txt

CMD ["bash"]
# Install Daytona stub package (satisfies imports without real Daytona SDK)
# The real Daytona SDK is not on PyPI; this stub allows all agents to load.
# Sandbox operations will raise NotImplementedError if called without a real key.
RUN pip install --no-cache-dir ./daytona_stub/

# Install Playwright Chromium browser binaries
RUN playwright install chromium

# Create workspace directory for agent file output (Railway Volume mounts here)
RUN mkdir -p /app/OpenManus/workspace

# Expose default port (Railway overrides via $PORT env var at runtime)
EXPOSE 8000

# Start the MCP server in SSE (web) mode
CMD ["python", "run_mcp_server.py", "--transport", "sse"]
15 changes: 12 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import os
import re
import threading
import tomllib
from pathlib import Path
Expand Down Expand Up @@ -106,7 +108,7 @@ class SandboxSettings(BaseModel):


class DaytonaSettings(BaseModel):
daytona_api_key: str
daytona_api_key: Optional[str] = Field(None, description="Daytona API key (optional, only needed for sandbox mode)")
daytona_server_url: Optional[str] = Field(
"https://app.daytona.io/api", description=""
)
Expand Down Expand Up @@ -227,8 +229,15 @@ def _get_config_path() -> Path:

def _load_config(self) -> dict:
config_path = self._get_config_path()
with config_path.open("rb") as f:
return tomllib.load(f)
# Read as text first so we can substitute ${VAR} placeholders from env
with config_path.open("r", encoding="utf-8") as f:
content = f.read()
# Replace all ${VAR_NAME} placeholders with environment variable values
for placeholder in re.findall(r"\$\{([^}]+)\}", content):
env_value = os.getenv(placeholder)
if env_value is not None:
content = content.replace(f"${{{placeholder}}}", env_value)
return tomllib.loads(content)

def _load_initial_config(self):
raw_config = self._load_config()
Expand Down
117 changes: 117 additions & 0 deletions app/mcp/agent_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
AgentTool: Wraps any OpenManus agent as a stateless, MCP-compatible tool.

Each call creates a fresh agent instance, runs it to completion, and cleans up.
This ensures thread safety and predictable concurrent operation.

Key design decision: `parameters` is a plain dict field (not a @property) so
that Pydantic's to_param() correctly serializes it instead of returning the
property descriptor object. Using @property causes Pydantic to return the
descriptor itself rather than calling it, resulting in:
AttributeError: 'property' object has no attribute 'get'
"""
import asyncio
from typing import Any, Type

from pydantic import Field

from app.logger import logger
from app.tool.base import BaseTool, ToolResult


# The parameters schema for all AgentTool instances - a single string prompt.
_AGENT_TOOL_PARAMETERS = {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": (
"The natural language task description for the agent to execute. "
"Be as specific and detailed as possible."
),
}
},
"required": ["prompt"],
}


class AgentTool(BaseTool):
"""
A generic wrapper that exposes an OpenManus agent as a single-call MCP tool.

The agent is instantiated fresh on every execute() call, ensuring complete
isolation between requests. This is the "Agent-as-a-Tool" pattern.
"""

# Store agent_class as a plain field (not Pydantic-managed) to avoid
# issues with non-serializable types.
agent_class: Any = Field(default=None, exclude=True)
agent_config: dict = Field(default_factory=dict, exclude=True)

# parameters MUST be a plain dict field (NOT a @property) so that
# BaseTool.to_param() serializes it correctly via self.parameters.
# Pydantic returns the property descriptor object instead of calling it,
# which breaks the MCP server's docstring/signature building.
parameters: dict = Field(default_factory=lambda: _AGENT_TOOL_PARAMETERS.copy())

class Config:
arbitrary_types_allowed = True

def __init__(self, agent_class: Type, **agent_config):
agent_name = getattr(agent_class, "name", agent_class.__name__).lower()
# Replace spaces/dashes for a clean tool name
tool_name = f"run_{agent_name.replace('-', '_').replace(' ', '_')}"
agent_description = getattr(agent_class, "description", "An OpenManus agent.")

super().__init__(
name=tool_name,
description=(
f"{agent_description}\n\n"
f"Runs the {agent_class.__name__} agent autonomously to completion. "
f"Provide a detailed natural language prompt describing the task."
),
parameters=_AGENT_TOOL_PARAMETERS.copy(),
)
# Store via object.__setattr__ to bypass pydantic immutability
object.__setattr__(self, "agent_class", agent_class)
object.__setattr__(self, "agent_config", agent_config)

async def execute(self, prompt: str, **kwargs) -> ToolResult:
"""
Creates a fresh agent instance, runs it with the given prompt,
and ensures cleanup regardless of success or failure.
"""
agent_class = object.__getattribute__(self, "agent_class")
agent_config = object.__getattribute__(self, "agent_config")

logger.info(
f"[AgentTool] Spawning {agent_class.__name__} for prompt: {prompt[:80]}..."
)
agent = None
try:
# Use async factory .create() if available (e.g., Manus), else direct init
if hasattr(agent_class, "create") and asyncio.iscoroutinefunction(
agent_class.create
):
agent = await agent_class.create(**agent_config)
else:
agent = agent_class(**agent_config)

result = await agent.run(prompt)
logger.info(f"[AgentTool] {agent_class.__name__} completed successfully.")
return ToolResult(output=result)

except Exception as e:
logger.error(
f"[AgentTool] {agent_class.__name__} failed: {e}", exc_info=True
)
return ToolResult(output=f"Agent execution failed: {str(e)}", error=str(e))

finally:
if agent is not None and hasattr(agent, "cleanup"):
try:
await agent.cleanup()
except Exception as cleanup_err:
logger.warning(
f"[AgentTool] Cleanup failed for {agent_class.__name__}: {cleanup_err}"
)
Loading