File size: 6,249 Bytes
d57737f
df568bc
 
d57737f
 
df568bc
 
d57737f
df568bc
 
 
d57737f
 
18d1b65
df568bc
18d1b65
 
ca3b01f
df568bc
d57737f
18d1b65
 
 
 
 
 
 
 
 
d57737f
 
 
18d1b65
 
 
 
 
d57737f
18d1b65
d57737f
 
 
 
 
 
18d1b65
 
74174cc
df568bc
74174cc
 
 
 
 
df568bc
18d1b65
 
 
 
 
 
 
 
 
 
df568bc
18d1b65
 
 
 
 
 
 
 
 
 
 
 
 
df568bc
 
 
0a9af47
cba593c
 
 
 
 
 
18d1b65
 
 
cba593c
 
df568bc
cba593c
18d1b65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df568bc
d57737f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a9af47
 
92b9ac9
 
ca3b01f
8fb4488
ca3b01f
8fb4488
ca3b01f
 
 
 
8fb4488
 
 
ca3b01f
 
 
df568bc
0a9af47
92b9ac9
 
ca3b01f
 
92b9ac9
 
0a9af47
 
 
ca3b01f
 
0a9af47
 
065d894
 
 
 
 
 
 
 
 
7738e45
065d894
 
 
d57737f
ca3b01f
 
 
 
 
 
 
d57737f
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
"""
Each environment in Agent World Model is a self-contained FastAPI application
with SQLAlchemy/SQLite backend and MCP tool interface.

Usage:
    PYTHONPATH=src:envs uvicorn envs.agent_world_model_env.server.app:app \\
        --host 0.0.0.0 --port 8000

HTTP /reset and /step are disabled because AWM requires stateful WebSocket
connections — each HTTP request would create a fresh environment, dropping
the subprocess and tool cache.
"""

import os
import uvicorn

import gradio as gr
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse

from openenv.core.env_server.http_server import create_app

from ..models import AWMAction, AWMObservation
from .awm_environment import AWMEnvironment
from .config import MAX_CONCURRENT_ENVS
from .data_loader import AWMDataLoader
from .session_registry import registry as _registry
from .web_ui import build_awm_gradio_app


_shared_data_loader = AWMDataLoader()


def _env_factory():
    return AWMEnvironment(data_loader=_shared_data_loader)


app = create_app(
    _env_factory,
    AWMAction,
    AWMObservation,
    env_name="agent_world_model_env",
    max_concurrent_envs=MAX_CONCURRENT_ENVS,
)


def _swap_in_custom_gradio_ui() -> None:
    """Replace openenv-core's default web UI with the AWM Web Console.

    The framework's ``gradio_builder`` parameter wraps our blocks inside a
    ``Playground | Custom`` TabbedInterface, which we don't want. Instead we
    let the framework set up its default UI, then drop the default Mount +
    legacy ``/web/*`` HTTP endpoints and mount our own blocks at ``/web``.
    Pulls ``WebInterfaceManager`` out of the existing route closures.
    """
    if os.environ.get("ENABLE_WEB_INTERFACE", "false").lower() not in (
        "true",
        "1",
        "yes",
    ):
        return

    web_manager = None
    metadata = None
    for r in app.routes:
        for cell in getattr(getattr(r, "endpoint", None), "__closure__", None) or ():
            try:
                v = cell.cell_contents
            except ValueError:
                continue
            if web_manager is None and v.__class__.__name__ == "WebInterfaceManager":
                web_manager = v
            if metadata is None and v.__class__.__name__ == "EnvironmentMetadata":
                metadata = v
        if web_manager is not None and metadata is not None:
            break
    if web_manager is None:
        return

    # /web in 0.2.1 is the legacy "HumanAgent Interface" HTMLResponse, not a
    # redirect — drop it together with the rest of the default UI's HTTP API.
    legacy_paths = {
        "/web",
        "/web/reset",
        "/web/step",
        "/web/state",
        "/web/metadata",
        "/ws/ui",
    }
    app.routes[:] = [
        r
        for r in app.routes
        if not (
            (getattr(r, "path", None) == "/web" and r.__class__.__name__ == "Mount")
            or getattr(r, "path", None) in legacy_paths
        )
    ]

    blocks = build_awm_gradio_app(
        web_manager,
        action_fields=None,
        metadata=metadata,
        is_chat_env=False,
        title="agent_world_model_env",
        quick_start_md=None,
    )
    gr.mount_gradio_app(app, blocks, path="/web")


_swap_in_custom_gradio_ui()


_HTTP_NOT_SUPPORTED_RESPONSE = {
    "error": "HTTP mode not supported for AWM environment",
    "reason": "AWM launches subprocesses on reset() that must persist across step() calls. "
    "HTTP is stateless - each request creates a new environment instance, "
    "losing the subprocess and all loaded tools.",
    "solution": "Use WebSocket endpoint instead",
    "examples": [
        "Python: AWMEnv(base_url='http://host:port')  # uses /ws internally",
        "Direct: connect to ws://host:port/ws",
    ],
}

app.routes[:] = [
    r for r in app.routes if getattr(r, "path", None) not in ("/reset", "/step")
]


@app.post("/reset", tags=["disabled"])
async def reset_not_supported():
    return JSONResponse(status_code=400, content=_HTTP_NOT_SUPPORTED_RESPONSE)


@app.post("/step", tags=["disabled"])
async def step_not_supported():
    return JSONResponse(status_code=400, content=_HTTP_NOT_SUPPORTED_RESPONSE)


@app.get("/stats", tags=["monitoring"])
async def stats():
    return JSONResponse(content=_registry.get_stats())


def _has_route(path: str) -> bool:
    return any(getattr(r, "path", None) == path for r in app.routes)


def _https_aware_redirect(request: Request, path: str) -> RedirectResponse:
    # HF's reverse proxy rewrites relative redirects into absolute URLs and
    # picks the scheme from the upstream request — which is HTTP. Build an
    # explicit absolute URL with the original scheme so the iframe doesn't
    # get blocked as mixed content.
    host = request.headers.get("x-forwarded-host") or request.headers.get(
        "host", request.url.netloc
    )
    proto = request.headers.get("x-forwarded-proto") or (
        "https" if host.endswith(".hf.space") else request.url.scheme
    )
    return RedirectResponse(url=f"{proto}://{host}{path}")


# 0.2.1 doesn't auto-redirect / and /web to /web/. HF Spaces hits both.
if not _has_route("/"):

    @app.get("/", include_in_schema=False)
    async def _root_redirect(request: Request):
        return _https_aware_redirect(request, "/web/")


if not _has_route("/web"):

    @app.get("/web", include_in_schema=False)
    async def _web_redirect(request: Request):
        return _https_aware_redirect(request, "/web/")


@app.middleware("http")
async def _force_https_redirects(request: Request, call_next):
    # HF Spaces' reverse proxy strips the original https scheme; any
    # absolute Location header we emit goes out as http:// which gets
    # blocked as mixed content inside the HF iframe. Force https for
    # *.hf.space hosts.
    response = await call_next(request)
    loc = response.headers.get("location")
    if loc and loc.startswith("http://") and ".hf.space" in loc:
        response.headers["location"] = "https://" + loc[len("http://") :]
    return response


def main():
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=8000,
        proxy_headers=True,
        forwarded_allow_ips="*",
    )


if __name__ == "__main__":
    main()