Create MVP RemoteAgent
parent
b563c8422f
commit
5c3eb14c17
|
|
@ -10,8 +10,11 @@
|
||||||
from .agent import Agent
|
from .agent import Agent
|
||||||
from .chaos import AgentOfChaos
|
from .chaos import AgentOfChaos
|
||||||
from .mute import MuteAgent
|
from .mute import MuteAgent
|
||||||
|
from .remote import RemoteAgent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Agent",
|
"Agent",
|
||||||
|
"AgentOfChaos",
|
||||||
"MuteAgent",
|
"MuteAgent",
|
||||||
|
"RemoteAgent",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
The remote agent is the agent that is accessible on the internet.
|
||||||
|
|
||||||
|
The common implementation of the back-end is the PyClient, although the
|
||||||
|
remote agent communicates through an API so the agent is
|
||||||
|
implementation-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .agent import Agent, Payload
|
||||||
|
|
||||||
|
class RemoteAgent(Agent):
|
||||||
|
"""
|
||||||
|
The RemoteAgent is the agent that is remotely accessible on an external
|
||||||
|
URL through the specified protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url : str
|
||||||
|
|
||||||
|
def __init__(self, url : str, timeout : float = 1.0) -> None:
|
||||||
|
"""
|
||||||
|
Create a new instance of the remote agent.
|
||||||
|
|
||||||
|
:param url: URL at which the agent can be accessed.
|
||||||
|
:type url: str
|
||||||
|
:param timeout: The maximum time the client is allowed to respond.
|
||||||
|
:type timeout: float
|
||||||
|
:raises requests.HTTPError: The server could not be reached.
|
||||||
|
:raises ValueError: The server returned an invalid response.
|
||||||
|
"""
|
||||||
|
self.url = url.strip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
# Get discovery from URL
|
||||||
|
response = requests.get(self.url.strip("/") + "/", timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"Remote agent's discovery response must be a JSON object."
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_author = content.get("author", content.get("me.noordstar.peanuts.author", ""))
|
||||||
|
raw_name = content.get("name", "")
|
||||||
|
raw_version = content.get("version", content.get("me.noordstar.peanuts.agent.version", None))
|
||||||
|
|
||||||
|
# Initialize agent based on given information
|
||||||
|
super().__init__(
|
||||||
|
author="" if raw_author is None else str(raw_author),
|
||||||
|
name="" if raw_name is None else str(raw_name),
|
||||||
|
version=None if raw_version is None else str(raw_version),
|
||||||
|
profile=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
games = content.get("games")
|
||||||
|
if isinstance(games, dict):
|
||||||
|
if "tic-tac-toe" in games:
|
||||||
|
self.add_tic_tac_toe(
|
||||||
|
on_move=lambda x : self.poll("tic-tac-toe", "", x),
|
||||||
|
profile=games["tic-tac-toe"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_action(self, game: str, action: str | None) -> Callable[[dict[str, Any]], dict[str, Any]] | None:
|
||||||
|
"""
|
||||||
|
Get an action function for any given game.
|
||||||
|
|
||||||
|
:param game: The name of the game.
|
||||||
|
:type game: str
|
||||||
|
:param action: The name of the action, or None if the game only has one action.
|
||||||
|
:type action: str | None
|
||||||
|
:return: The action with which the agent would like to act, if at all.
|
||||||
|
:rtype: Callable[[dict[str, Any]], dict[str, Any]] | None
|
||||||
|
"""
|
||||||
|
return lambda x : self.poll(game=game, action=action or "", payload=x)
|
||||||
|
|
||||||
|
def poll(
|
||||||
|
self,
|
||||||
|
game : str,
|
||||||
|
action : str,
|
||||||
|
payload : Payload
|
||||||
|
) -> Payload:
|
||||||
|
"""
|
||||||
|
Inquire the remote agent for a response.
|
||||||
|
"""
|
||||||
|
url = "/".join([self.url, game.strip("/"), action.strip("/")]).strip("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, json=payload, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()
|
||||||
|
except (requests.RequestException, requests.HTTPError, ValueError):
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
return content if isinstance(content, dict) else {}
|
||||||
|
|
@ -2,58 +2,43 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agents import Agent
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, request
|
from flask import Flask, Response, jsonify, request
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
PayloadType = dict[str, Any]
|
PayloadType = dict[str, Any]
|
||||||
GameHandler = Callable[[PayloadType], PayloadType]
|
GameHandler = Callable[[PayloadType], PayloadType]
|
||||||
|
|
||||||
|
|
||||||
class PyServer:
|
class PyServer:
|
||||||
"""A tiny stateless Flask app that serves discovery and game routes."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
name: str,
|
|
||||||
import_name: str = __name__,
|
|
||||||
profile: Optional[PayloadType] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Create a PyServer.
|
A tiny stateless Flask app that serves discovery and routes
|
||||||
|
game behavior to an agent.
|
||||||
|
"""
|
||||||
|
|
||||||
:param name: Preferred display name for discovery.
|
def __init__(self, agent : Agent, import_name : str = __name__) -> None:
|
||||||
:type name: str
|
"""
|
||||||
:param import_name: Flask import name.
|
Create a PyServer that serves the behavior of an Agent.
|
||||||
|
|
||||||
|
:param agent: The agent that one can communicate with.
|
||||||
|
:type agent: Agent
|
||||||
|
:param import_name: Flask application import name
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
:param profile: Additional root-level discovery metadata.
|
|
||||||
:type profile: Optional[Dict[str, Any]]
|
|
||||||
:raises ValueError: The input contains invalid information.
|
|
||||||
"""
|
"""
|
||||||
self.name = name
|
self.agent = agent
|
||||||
self.profile = dict(profile or {})
|
|
||||||
|
|
||||||
if "name" in self.profile:
|
|
||||||
raise ValueError(
|
|
||||||
"Root profile metadata must not define 'name'."
|
|
||||||
)
|
|
||||||
if "games" in self.profile:
|
|
||||||
raise ValueError(
|
|
||||||
"Root profile metadata must not define 'games'."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app = Flask(import_name)
|
self.app = Flask(import_name)
|
||||||
self.__games: dict[str, PayloadType] = {}
|
|
||||||
self.__registered_routes: set[str] = set()
|
|
||||||
|
|
||||||
# Register the root
|
|
||||||
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
|
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
|
||||||
# self.app.add_url_rule("/",
|
|
||||||
# endpoint="botman_discovery",
|
# Register endpoints
|
||||||
# view_func=self.__discovery,
|
for name, (_, endpoints) in self.agent.registered_games.items():
|
||||||
# methods=["GET"]
|
if name == "":
|
||||||
# )
|
continue
|
||||||
|
|
||||||
|
for route, func in endpoints.items():
|
||||||
|
self.__add_api_endpoint(name=name, route=route, func=func)
|
||||||
|
|
||||||
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None:
|
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -67,13 +52,6 @@ class PyServer:
|
||||||
"""
|
"""
|
||||||
url = self.__make_url(name, route)
|
url = self.__make_url(name, route)
|
||||||
|
|
||||||
if url in self.__registered_routes:
|
|
||||||
raise ValueError(
|
|
||||||
f"Route already registered: {url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.__registered_routes.add(url)
|
|
||||||
|
|
||||||
return self.app.add_url_rule(url,
|
return self.app.add_url_rule(url,
|
||||||
endpoint="botman_" + name.replace("/", "_"),
|
endpoint="botman_" + name.replace("/", "_"),
|
||||||
view_func=self.__func_wrapper(func),
|
view_func=self.__func_wrapper(func),
|
||||||
|
|
@ -87,11 +65,17 @@ class PyServer:
|
||||||
:return: The personal discovery information.
|
:return: The personal discovery information.
|
||||||
:rtype: dict[str, Any]
|
:rtype: dict[str, Any]
|
||||||
"""
|
"""
|
||||||
return {
|
d = dict(name=self.agent.name, author=self.agent.author)
|
||||||
"name": self.name,
|
if self.agent.version is not None:
|
||||||
"games": dict(self.__games),
|
d["version"] = self.agent.version
|
||||||
**self.profile,
|
|
||||||
}
|
d = { **self.agent.profile, **d }
|
||||||
|
|
||||||
|
d["games"] = {}
|
||||||
|
for game, (profile, _) in self.agent.registered_games.items():
|
||||||
|
d["games"][game] = profile
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
|
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -109,82 +93,6 @@ class PyServer:
|
||||||
def __make_url(self, name : str, route : str) -> str:
|
def __make_url(self, name : str, route : str) -> str:
|
||||||
return "/" + "/".join([ name.strip("/"), route.strip("/") ]).strip("/")
|
return "/" + "/".join([ name.strip("/"), route.strip("/") ]).strip("/")
|
||||||
|
|
||||||
def add_game(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
profile: PayloadType,
|
|
||||||
actions: dict[str, GameHandler],
|
|
||||||
required_actions: Optional[list[str]] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Register a stateless game with one or more action routes.
|
|
||||||
|
|
||||||
:param name: Base route name for the game.
|
|
||||||
:type name: str
|
|
||||||
:param profile: Game-specific discovery metadata.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
:param actions: Mapping of action subpaths to handlers.
|
|
||||||
:type actions: dict[str, Callable[[dict[str, Any]], dict[str, Any]]]
|
|
||||||
:param required_actions: Optional completeness check. If provided, all
|
|
||||||
required action names must exist in ``actions`` and map to callables.
|
|
||||||
:type required_actions: Optional[list[str]]
|
|
||||||
:raises AssertionError: Some of the required actions aren't present.
|
|
||||||
:raises ValueError: Some of the requested URL paths are already occupied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Verify that all required actions are present
|
|
||||||
if required_actions is not None:
|
|
||||||
missing_actions = [ action for action in required_actions if action not in actions ]
|
|
||||||
|
|
||||||
if len(missing_actions) > 0:
|
|
||||||
raise AssertionError(
|
|
||||||
f"Missing required action handlers: {', '.join(sorted(missing_actions))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify that there are no duplicate URLs being registered
|
|
||||||
# Even though this will automatically be checked later, checking this
|
|
||||||
# now ensures that the operation is atomic and doesn't add
|
|
||||||
# games partially.
|
|
||||||
new_routes : set[str] = set()
|
|
||||||
|
|
||||||
for route in actions:
|
|
||||||
url = self.__make_url(name, route)
|
|
||||||
|
|
||||||
if url in self.__registered_routes:
|
|
||||||
raise ValueError(
|
|
||||||
"Route {url} was already registered"
|
|
||||||
)
|
|
||||||
if url in new_routes:
|
|
||||||
raise ValueError(
|
|
||||||
"Route {url} was registered twice by the same function call"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_routes.add(url)
|
|
||||||
|
|
||||||
# Register all actions
|
|
||||||
for route, func in actions.items():
|
|
||||||
self.__add_api_endpoint(name=name, route=route, func=func)
|
|
||||||
|
|
||||||
# Add profile data
|
|
||||||
self.__games[name] = profile
|
|
||||||
|
|
||||||
def add_tic_tac_toe(self, on_move: GameHandler, profile: PayloadType = {}) -> None:
|
|
||||||
"""
|
|
||||||
Convenience registration for tic-tac-toe.
|
|
||||||
|
|
||||||
The game is exposed at `/tic-tac-toe`.
|
|
||||||
|
|
||||||
:param profile: The player's custom profile.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
:param on_move:
|
|
||||||
"""
|
|
||||||
self.add_game(
|
|
||||||
name="tic-tac-toe",
|
|
||||||
profile=profile,
|
|
||||||
actions={"": on_move},
|
|
||||||
required_actions=[""],
|
|
||||||
)
|
|
||||||
|
|
||||||
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
||||||
"""Start the Flask development server."""
|
"""Start the Flask development server."""
|
||||||
self.app.run(host=host, port=port, debug=debug, **kwargs)
|
self.app.run(host=host, port=port, debug=debug, **kwargs)
|
||||||
|
|
|
||||||
74
server.py
74
server.py
|
|
@ -4,10 +4,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import agents
|
||||||
|
|
||||||
from pyserver import PyServer
|
from pyserver import PyServer
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""
|
"""
|
||||||
|
|
@ -16,19 +15,7 @@ def main() -> int:
|
||||||
:return: Exit code
|
:return: Exit code
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
player = PyServer(
|
player = PyServer(agents.RemoteAgent(url="https://bmt001.noordstar.me/"))
|
||||||
# Customize this to whatever you'd like to call your player
|
|
||||||
name="My super smart robot player",
|
|
||||||
|
|
||||||
# Custom information that you can use to tell people about this player
|
|
||||||
profile={},
|
|
||||||
|
|
||||||
# Unless you know what you're doing, don't touch this.
|
|
||||||
import_name=__name__,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register games! Comment out any you don't want your player to play.
|
|
||||||
player.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={})
|
|
||||||
|
|
||||||
# Start listening for games
|
# Start listening for games
|
||||||
player.start(
|
player.start(
|
||||||
|
|
@ -38,62 +25,5 @@ def main() -> int:
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def play_tic_tac_toe(payload : dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Play a game of tic-tac-toe.
|
|
||||||
|
|
||||||
You receive a payload that looks like this:
|
|
||||||
|
|
||||||
{
|
|
||||||
"1": "X", "2": "", "3": "O",
|
|
||||||
"4": "X", "5": "O", "6": "",
|
|
||||||
"7": "", "8": "", "9": "",
|
|
||||||
"your_token": "X"
|
|
||||||
}
|
|
||||||
|
|
||||||
And you're expected to return a response of which field you'd like to
|
|
||||||
place your piece in. For example, if you wish to place your token in
|
|
||||||
field 7, your response should look like this:
|
|
||||||
|
|
||||||
{ "move": 7 }
|
|
||||||
|
|
||||||
The board is arranged as follows:
|
|
||||||
|
|
||||||
1 | 2 | 3
|
|
||||||
---+---+---
|
|
||||||
4 | 5 | 6
|
|
||||||
---+---+---
|
|
||||||
7 | 8 | 9
|
|
||||||
|
|
||||||
:param payload: The incoming JSON that contains the game state.
|
|
||||||
:type payload: dict[str, Any]
|
|
||||||
:return: The move you wish to take.
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Try printing the payload to see what it looks like!
|
|
||||||
print(payload)
|
|
||||||
|
|
||||||
options = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, ]
|
|
||||||
|
|
||||||
# 1. Try filtering out the impossible moves!
|
|
||||||
# If an X or O was already placed at a field, remove it from the options
|
|
||||||
|
|
||||||
#
|
|
||||||
|
|
||||||
# 2. Try finding two in a row! If possible, you can try to place the third
|
|
||||||
# item on the board and get 3 in a row.
|
|
||||||
|
|
||||||
#
|
|
||||||
|
|
||||||
# 3. Perhaps you can block the opponent from getting 3 in a row?
|
|
||||||
|
|
||||||
#
|
|
||||||
|
|
||||||
# Now, pick any of the remaining options.
|
|
||||||
# This is just a simple implementation. Naturally, you're welcome to try
|
|
||||||
# your own logic.
|
|
||||||
return { "move": random.choice(options) }
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue