Skip to content

Commit

Permalink
Support runnning Mesop based apps with WSGI servers (#283)
Browse files Browse the repository at this point in the history
* wip

* wip

* wip

* WSGI and ASGI protocols added and WSGI implemented for MesopUI

* docs added

* skip tests failed due to internal server error in openai
  • Loading branch information
davorrunje authored Sep 27, 2024
1 parent da9e80b commit af4e914
Show file tree
Hide file tree
Showing 22 changed files with 262 additions and 28 deletions.
4 changes: 4 additions & 0 deletions docs/docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ search:
- app
- [FastAgency](api/fastagency/app/FastAgency.md)
- base
- [ASGI](api/fastagency/base/ASGI.md)
- [AskingMessage](api/fastagency/base/AskingMessage.md)
- [FunctionCallExecution](api/fastagency/base/FunctionCallExecution.md)
- [IOMessage](api/fastagency/base/IOMessage.md)
Expand All @@ -64,6 +65,7 @@ search:
- [TextInput](api/fastagency/base/TextInput.md)
- [TextMessage](api/fastagency/base/TextMessage.md)
- [UI](api/fastagency/base/UI.md)
- [WSGI](api/fastagency/base/WSGI.md)
- [WorkflowCompleted](api/fastagency/base/WorkflowCompleted.md)
- [Workflows](api/fastagency/base/Workflows.md)
- [run_workflow](api/fastagency/base/run_workflow.md)
Expand All @@ -85,9 +87,11 @@ search:
- logging
- [setup_logging](api/fastagency/cli/logging/setup_logging.md)
- exceptions
- [FastAgencyASGINotImplementedError](api/fastagency/exceptions/FastAgencyASGINotImplementedError.md)
- [FastAgencyCLIError](api/fastagency/exceptions/FastAgencyCLIError.md)
- [FastAgencyCLIPythonVersionError](api/fastagency/exceptions/FastAgencyCLIPythonVersionError.md)
- [FastAgencyError](api/fastagency/exceptions/FastAgencyError.md)
- [FastAgencyWSGINotImplementedError](api/fastagency/exceptions/FastAgencyWSGINotImplementedError.md)
- logging
- [get_logger](api/fastagency/logging/get_logger.md)
- runtime
Expand Down
11 changes: 11 additions & 0 deletions docs/docs/en/api/fastagency/base/ASGI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
# 0.5 - API
# 2 - Release
# 3 - Contributing
# 5 - Template Page
# 10 - Default
search:
boost: 0.5
---

::: fastagency.base.ASGI
11 changes: 11 additions & 0 deletions docs/docs/en/api/fastagency/base/WSGI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
# 0.5 - API
# 2 - Release
# 3 - Contributing
# 5 - Template Page
# 10 - Default
search:
boost: 0.5
---

::: fastagency.base.WSGI
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
# 0.5 - API
# 2 - Release
# 3 - Contributing
# 5 - Template Page
# 10 - Default
search:
boost: 0.5
---

::: fastagency.exceptions.FastAgencyASGINotImplementedError
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
# 0.5 - API
# 2 - Release
# 3 - Contributing
# 5 - Template Page
# 10 - Default
search:
boost: 0.5
---

::: fastagency.exceptions.FastAgencyWSGINotImplementedError
35 changes: 30 additions & 5 deletions docs/docs/en/user-guide/ui/mesop/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,38 @@ app = FastAgency(wf=wf, ui=MesopUI())

### Running the Application

Once the workflow is set up, you can run the application using the **FastAgency CLI**. Navigate to the directory where the script is located and run the following command:
Once the workflow is set up, you can run the application either:

```bash
fastagency run
```
- **locally** using the [FastAgency CLI](../../../cli/), or

- **publicly** using the WSGI HTTP Server such as [Gunicorn](https://gunicorn.org/).

=== "Local deployment"

Navigate to the directory where the script is located and run the following command:

```bash
fastagency run
```

This will launch a local web server, and you will be able to access the MesopUI interface through your browser. The web interface will display the interaction between the student and teacher agents, allowing you to input questions and see the teacher’s responses.

This will launch a local web server, and you will be able to access the MesopUI interface through your browser. The web interface will display the interaction between the student and teacher agents, allowing you to input questions and see the teacher’s responses.
=== "Public deployment"
Assuming that you installed gunicorn first using something like this:

```console
pip install "fastagency[autogen,mesop]" gunicorn
```

you can start the Mesop app by navigating to the directory where the script `main.py` is located and running the following command:

```bash
gunicorn --bind 0.0.0.0:8080 main:app
```

This will launch a *publicly available* web server, and you will be able to access the MesopUI interface through your browser. The web interface will display the interaction between the student and teacher agents, allowing you to input questions and see the teacher’s responses.

---

!!! note
Ensure that your OpenAI API key is set in the environment, as the agents rely on it to interact using GPT-4o. If the API key is not correctly configured, the application may fail to retrieve LLM-powered responses.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs_src/getting_started/main_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def simple_workflow(
teacher_agent,
message=initial_message,
summary_method="reflection_with_llm",
max_turns=5,
max_turns=3,
)

return chat_result.summary # type: ignore[no-any-return]
Expand Down
51 changes: 48 additions & 3 deletions fastagency/app.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
__all__ = ["FastAgency"]

from collections.abc import Generator
from collections.abc import Awaitable, Generator
from contextlib import contextmanager
from typing import Optional
from typing import Any, Callable, Optional, Union

from .base import UI, Workflows
from .base import ASGI, UI, WSGI, Workflows
from .exceptions import (
FastAgencyASGINotImplementedError,
FastAgencyWSGINotImplementedError,
)


class FastAgency: # Runnable
Expand Down Expand Up @@ -49,3 +53,44 @@ def start(
initial_message=initial_message,
single_run=single_run,
)

def __call__(self, *args: Any) -> Union[Awaitable[None], list[bytes]]:
if len(args) == 2 and callable(args[1]):
# WSGI interface
environ, start_response = args
return self.handle_wsgi(environ, start_response)
elif len(args) == 3 and callable(args[1]) and callable(args[2]):
# ASGI interface
scope, receive, send = args
scope_type = scope.get("type")
if scope_type == "http":
return self.handle_asgi(scope, receive, send)
else:
raise NotImplementedError(
f"ASGI scope type '{scope_type}' not supported."
)
else:
raise TypeError(f"Invalid arguments for __call__: {args}")

def handle_wsgi(
self, environ: dict[str, Any], start_response: Callable[..., Any]
) -> list[bytes]:
if isinstance(self.ui, WSGI):
return self.ui.handle_wsgi(self, environ, start_response)
else:
raise FastAgencyWSGINotImplementedError(
"WSGI interface not supported for UI: {self.ui}"
)

async def handle_asgi(
self,
scope: dict[str, Any],
receive: Callable[[dict[str, Any]], Awaitable[None]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
if isinstance(self.ui, ASGI):
return await self.ui.handle_asgi(self, scope, receive, send)
else:
raise FastAgencyASGINotImplementedError(
"ASGI interface not supported for UI: {self.ui}"
)
23 changes: 22 additions & 1 deletion fastagency/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
import textwrap
from abc import ABC, abstractmethod
from collections.abc import Generator, Iterable, Iterator, Mapping
from collections.abc import Awaitable, Generator, Iterable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field, fields
from typing import (
Expand Down Expand Up @@ -225,6 +225,27 @@ def process_message(self, message: IOMessage) -> Optional[str]: ...
def create_subconversation(self) -> "UI": ...


@runtime_checkable
class WSGI(Protocol):
def handle_wsgi(
self,
app: "Runnable",
environ: dict[str, Any],
start_response: Callable[..., Any],
) -> list[bytes]: ...


@runtime_checkable
class ASGI(Protocol):
async def handle_asgi(
self,
app: "Runnable",
scope: dict[str, Any],
receive: Callable[[dict[str, Any]], Awaitable[None]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None: ...


Workflow = TypeVar("Workflow", bound=Callable[["Workflows", UI, str, str], str])


Expand Down
8 changes: 8 additions & 0 deletions fastagency/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ class FastAgencyCLIError(FastAgencyError):

class FastAgencyCLIPythonVersionError(FastAgencyCLIError):
pass


class FastAgencyWSGINotImplementedError(FastAgencyError):
pass


class FastAgencyASGINotImplementedError(FastAgencyError):
pass
30 changes: 29 additions & 1 deletion fastagency/ui/mesop/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from queue import Queue
from tempfile import TemporaryDirectory
from typing import ClassVar, Optional
from typing import Any, Callable, ClassVar, Optional
from uuid import uuid4

from mesop.bin.bin import FLAGS as MESOP_FLAGS
Expand Down Expand Up @@ -62,6 +62,7 @@ def __init__(self, super_conversation: "Optional[MesopUI]" = None) -> None:

@classmethod
def get_created_instance(cls) -> "MesopUI":
logger.info(f"Getting MesopUI created instance: {cls._created_instance}")
created_instance = cls._created_instance
if created_instance is None:
raise RuntimeError("MesopUI has not been created yet.")
Expand All @@ -70,6 +71,7 @@ def get_created_instance(cls) -> "MesopUI":

@property
def app(self) -> Runnable:
logger.info(f"Getting app: {MesopUI._app}")
app = MesopUI._app
if app is None:
raise RuntimeError("MesopUI has not been created yet.")
Expand Down Expand Up @@ -211,6 +213,20 @@ def get_message_stream(self) -> Generator[MesopMessage, None, None]:
break
yield message

def handle_wsgi(
self,
app: "Runnable",
environ: dict[str, Any],
start_response: Callable[..., Any],
) -> list[bytes]:
logger.info(f"Starting MesonUI using WSGI interface with app: {app}")
MesopUI._created_instance = self
MesopUI._app = app

from .main import me

return me(environ, start_response) # type: ignore[no-any-return]


def run_workflow(wf: Workflows, name: str, initial_message: str) -> MesopUI:
def conversation_worker(ui: MesopUI, subconversation: MesopUI) -> None:
Expand Down Expand Up @@ -283,3 +299,15 @@ def conversation_worker(ui: MesopUI, subconversation: MesopUI) -> None:
thread.start()

return subconversation

# # needed for uvicorn to recognize the class as a valid ASGI application
# async def __call__(
# self,
# scope: dict[str, Any],
# receive: Callable[[], Awaitable[dict]],
# send: Callable[[dict], Awaitable[None]],
# ) -> None:
# MesopUI._created_instance = self
# from .main import me

# return await me(scope, receive, send)
2 changes: 2 additions & 0 deletions fastagency/ui/mesop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
STYLESHEETS,
)

__all__ = ["me"]

# Get the logger
logger = get_logger(__name__)

Expand Down
6 changes: 5 additions & 1 deletion tests/docs_src/getting_started/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@
from fastagency.cli import app
from tests.conftest import InputMock

from ..helpers import skip_internal_server_error

runner = CliRunner()

INPUT_MESSAGE = "Help me learn a maths problem for 3rd grade"
INPUT_MESSAGE = "Who is Pitagora?"


@pytest.mark.openai
@skip_internal_server_error
def test_main(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("builtins.input", InputMock([INPUT_MESSAGE]))

result = runner.invoke(
app, ["run", "docs/docs_src/getting_started/main_console.py", "--single-run"]
)

assert result.exit_code == 0
assert INPUT_MESSAGE in result.stdout
assert "Teacher_Agent -> Student_Agent" in result.stdout
37 changes: 37 additions & 0 deletions tests/docs_src/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import functools
from typing import Any, Callable, TypeVar

import pytest

from fastagency.logging import get_logger

__all__ = ["skip_internal_server_error"]

logger = get_logger(__file__)

C = TypeVar("C", bound=Callable[..., Any])


def skip_internal_server_error(func: C) -> C:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except Exception as e:
logger.warning(f"skip_internal_server_error(): error detected: {e}")
logger.info(
f"skip_internal_server_error(): e.args[0] : {e.args[0]}"
)
if (
"InternalServerError" in e.args[0]
and "The model produced invalid content. Consider modifying your prompt if you are seeing this error persistently."
in e.args[0]
):
logger.warning(
"skip_internal_server_error(): Internal server error detected, marking the test as XFAIL"
)
pytest.xfail("Internal server error detected")
logger.error(f"skip_internal_server_error(): reraising: {e}")
raise

return wrapper # type: ignore[return-value]
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
from typer.testing import CliRunner

from fastagency.cli import app
from tests.conftest import InputMock

from ....conftest import InputMock
from ...helpers import skip_internal_server_error

runner = CliRunner()

INPUT_MESSAGE = "Today's theme is Leonardo da Vinci"


@pytest.mark.openai
@pytest.mark.xfail(strict=False)
@skip_internal_server_error
def test_main(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"builtins.input",
Expand Down
Loading

0 comments on commit af4e914

Please sign in to comment.