feat: basic function
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""Standalone interactive HTTP Human Agent.
|
||||
|
||||
Run this as a process on the operator's machine to expose a single
|
||||
``POST /act`` endpoint that the Texas Hold'em service can call when it is
|
||||
that operator's turn to act:
|
||||
|
||||
python -m texas_holdem.human_client --host 127.0.0.1 --port 9001
|
||||
|
||||
Then create a game on the server with this player spec::
|
||||
|
||||
{
|
||||
"id": "alice",
|
||||
"name": "Alice",
|
||||
"agent": {
|
||||
"type": "http",
|
||||
"endpoint": "http://127.0.0.1:9001/act",
|
||||
"timeout_seconds": 600
|
||||
}
|
||||
}
|
||||
|
||||
Every time the server posts an observation, this client renders it on the
|
||||
local terminal and blocks on stdin until the human chooses a legal action,
|
||||
then returns ``{"action": "...", "amount": N}`` as JSON.
|
||||
|
||||
Design notes:
|
||||
- The HTTP layer reuses :mod:`texas_holdem.human_io` so rendering and menu
|
||||
validation stay consistent with the in-process :class:`HumanAgent`.
|
||||
- A module-level :class:`threading.Lock` serialises terminal access. This is
|
||||
necessary because the (rare) case of multiple overlapping requests from
|
||||
the server must not interleave prompts on the same TTY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Lock
|
||||
from typing import IO, Any
|
||||
|
||||
from texas_holdem.human_io import prompt_action, render_observation
|
||||
|
||||
|
||||
class HumanClientConsole:
|
||||
"""Encapsulates terminal IO with a lock to serialise prompts.
|
||||
|
||||
Wrapping the streams in a tiny class keeps stream injection (handy for
|
||||
tests) and concurrency control in one place, instead of leaking through
|
||||
free functions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_stream: IO[str] | None = None,
|
||||
output_stream: IO[str] | None = None,
|
||||
) -> None:
|
||||
self._input = input_stream if input_stream is not None else sys.stdin
|
||||
self._output = output_stream if output_stream is not None else sys.stdout
|
||||
# The lock guards both the printed observation block and the prompt
|
||||
# loop so two concurrent /act calls would never interleave on the
|
||||
# same TTY.
|
||||
self._lock = Lock()
|
||||
|
||||
def decide(self, observation: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Render an observation and return the operator's action dict."""
|
||||
with self._lock:
|
||||
self._write(render_observation(observation))
|
||||
return prompt_action(
|
||||
list(observation.get("legal_actions") or []),
|
||||
self._read_line,
|
||||
self._write,
|
||||
)
|
||||
|
||||
def _write(self, text: str) -> None:
|
||||
self._output.write(text)
|
||||
self._output.flush()
|
||||
|
||||
def _read_line(self, prompt: str) -> str:
|
||||
self._write(prompt)
|
||||
line = self._input.readline()
|
||||
if line == "":
|
||||
raise EOFError("input stream closed while waiting for human action")
|
||||
return line.rstrip("\n")
|
||||
|
||||
|
||||
class HumanRequestHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP entry point for the standalone human agent.
|
||||
|
||||
Only ``POST /act`` is meaningful; ``GET /health`` is provided so deploys
|
||||
can quickly probe whether the client is alive before hooking it up.
|
||||
"""
|
||||
|
||||
server_version = "TexasHoldemHumanClient/0.1"
|
||||
|
||||
# Injected by :func:`create_server` on the underlying server instance so
|
||||
# every handler shares the same terminal console.
|
||||
console: HumanClientConsole # type: ignore[assignment]
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self._json({"ok": True})
|
||||
return
|
||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/act":
|
||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
try:
|
||||
payload = self._read_json()
|
||||
except ValueError as exc:
|
||||
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
|
||||
try:
|
||||
action = self.console.decide(payload)
|
||||
except EOFError as exc:
|
||||
# The operator closed stdin (Ctrl-D); surface as 503 so the
|
||||
# server can fall back to its default coercion (fold).
|
||||
self._json({"error": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
return
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
self._json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return
|
||||
|
||||
self._json(action)
|
||||
|
||||
# Silence the default access log so it does not interleave with prompts.
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
return
|
||||
|
||||
def _read_json(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0:
|
||||
raise ValueError("request body is required")
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("request body must be valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _json(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
status: HTTPStatus = HTTPStatus.OK,
|
||||
) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def create_server(
|
||||
host: str,
|
||||
port: int,
|
||||
console: HumanClientConsole | None = None,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build a server with a shared :class:`HumanClientConsole`.
|
||||
|
||||
Exposed as a function so tests (or callers wiring custom IO streams)
|
||||
can construct the server without touching ``main``.
|
||||
"""
|
||||
server = ThreadingHTTPServer((host, port), HumanRequestHandler)
|
||||
HumanRequestHandler.console = console or HumanClientConsole()
|
||||
return server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run an interactive HTTP Human Agent that exposes POST /act.",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", default=9001, type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
server = create_server(args.host, args.port)
|
||||
print(
|
||||
f"Human HTTP agent listening on http://{args.host}:{args.port}/act\n"
|
||||
"Use this URL as the 'endpoint' field of a 'http' agent spec.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user