feat: add human http agent

This commit is contained in:
qianrui.mmmy
2026-05-11 19:53:40 +08:00
parent e46b2b84c5
commit 6014ec0707
5 changed files with 335 additions and 41 deletions
+97 -29
View File
@@ -1,33 +1,37 @@
"""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:
Run this as a process on the operator's machine to expose:
* ``POST /act`` - the server posts the current observation; we render it
on the local terminal and block on stdin until the human picks a legal
action, then return ``{"action": ..., "amount": N}``.
* ``POST /game`` - the server posts the full game snapshot at the end of
every hand (same shape as ``GET /games/{id}``) so the operator sees how
the table is evolving. The body of the response is empty.
* ``GET /health`` - liveness probe.
Start the client:
python -m texas_holdem.human_client --host 127.0.0.1 --port 9001
Then create a game on the server with this player spec::
Hook it up by passing the *base* URL when creating the game::
{
"id": "alice",
"name": "Alice",
"agent": {
"type": "http",
"endpoint": "http://127.0.0.1:9001/act",
"endpoint": "http://127.0.0.1:9001",
"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.
- A :class:`threading.Lock` inside :class:`HumanClientConsole` serialises
terminal access so concurrent ``/act`` and ``/game`` callbacks never
interleave on the same TTY.
"""
from __future__ import annotations
@@ -40,7 +44,12 @@ 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
from texas_holdem.human_io import (
clear_screen,
prompt_action,
render_game_state,
render_observation,
)
class HumanClientConsole:
@@ -49,23 +58,32 @@ class HumanClientConsole:
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.
``keep_history`` defaults to ``False`` so every ``/act`` callback wipes
the terminal first; pass ``True`` to retain previous output (e.g. for
log-style debugging or when the terminal does not support ANSI codes).
"""
def __init__(
self,
input_stream: IO[str] | None = None,
output_stream: IO[str] | None = None,
keep_history: bool = False,
) -> 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.
# The lock guards every ``decide`` and ``announce_game`` call so two
# concurrent server callbacks never interleave on the same TTY.
self._lock = Lock()
self._keep_history = keep_history
def decide(self, observation: dict[str, Any]) -> dict[str, Any]:
"""Render an observation and return the operator's action dict."""
with self._lock:
# Clear-by-default keeps the focus on the current decision; only
# opt-out callers see the entire history scrolling upwards.
if not self._keep_history:
clear_screen(self._write)
self._write(render_observation(observation))
return prompt_action(
list(observation.get("legal_actions") or []),
@@ -73,6 +91,16 @@ class HumanClientConsole:
self._write,
)
def announce_game(self, game_state: dict[str, Any]) -> None:
"""Render an end-of-hand game snapshot to the operator's terminal.
Separated from :meth:`decide` because it is purely informational and
must never block on input; it just writes a digest under the same
lock to avoid corrupting an in-progress prompt.
"""
with self._lock:
self._write(render_game_state(game_state))
def _write(self, text: str) -> None:
self._output.write(text)
self._output.flush()
@@ -88,14 +116,17 @@ class HumanClientConsole:
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.
Routes:
* ``GET /health`` - liveness probe.
* ``POST /act`` - decision request (blocks on stdin).
* ``POST /game`` - end-of-hand snapshot (non-blocking).
"""
server_version = "TexasHoldemHumanClient/0.1"
server_version = "TexasHoldemHumanClient/0.2"
# Injected by :func:`create_server` on the underlying server instance so
# every handler shares the same terminal console.
# Injected by :func:`create_server` on the underlying server class so
# every handler shares the same terminal console (and lock).
console: HumanClientConsole # type: ignore[assignment]
def do_GET(self) -> None:
@@ -105,7 +136,14 @@ class HumanRequestHandler(BaseHTTPRequestHandler):
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path != "/act":
# Dispatch table keeps add/remove of routes mechanical and avoids
# the deeply-nested if/elif ladder common in BaseHTTPRequestHandler.
routes = {
"/act": self._handle_act,
"/game": self._handle_game,
}
handler = routes.get(self.path)
if handler is None:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
@@ -116,18 +154,24 @@ class HumanRequestHandler(BaseHTTPRequestHandler):
return
try:
action = self.console.decide(payload)
handler(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
def _handle_act(self, payload: dict[str, Any]) -> None:
action = self.console.decide(payload)
self._json(action)
def _handle_game(self, payload: dict[str, Any]) -> None:
# The /game callback is informational; reply with an empty 204 so
# the calling engine knows we received it but does not parse a body.
self.console.announce_game(payload)
self._empty(HTTPStatus.NO_CONTENT)
# Silence the default access log so it does not interleave with prompts.
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
return
@@ -156,6 +200,12 @@ class HumanRequestHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _empty(self, status: HTTPStatus) -> None:
"""Write a header-only response (used for ``204 No Content``)."""
self.send_response(status)
self.send_header("Content-Length", "0")
self.end_headers()
def create_server(
host: str,
@@ -174,16 +224,34 @@ def create_server(
def main() -> None:
parser = argparse.ArgumentParser(
description="Run an interactive HTTP Human Agent that exposes POST /act.",
description=(
"Run an interactive HTTP Human Agent that exposes "
"POST /act and POST /game."
),
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=9001, type=int)
# Default behaviour clears the terminal on every /act so the operator
# always sees a fresh view. Opt-in flag restores the historical
# "append forever" behaviour for log-style debugging.
parser.add_argument(
"--keep-history",
action="store_true",
help=(
"Keep previous terminal output when a new /act request arrives "
"instead of clearing the screen."
),
)
args = parser.parse_args()
server = create_server(args.host, args.port)
console = HumanClientConsole(keep_history=args.keep_history)
server = create_server(args.host, args.port, console=console)
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.",
f"Human HTTP agent listening on http://{args.host}:{args.port}\n"
f" POST /act - decision request\n"
f" POST /game - end-of-hand snapshot\n"
f" clear-screen: {'off (keep history)' if args.keep_history else 'on'}\n"
"Pass the base URL above as the 'endpoint' field of an 'http' agent spec.",
file=sys.stderr,
flush=True,
)