diff --git a/examples/parlant/01_basic_agent.py b/examples/parlant/01_basic_agent.py index ca9579a64..b538f72b9 100644 --- a/examples/parlant/01_basic_agent.py +++ b/examples/parlant/01_basic_agent.py @@ -29,6 +29,7 @@ from setup_logging import setup_logging from band import Agent from band.adapters import ParlantAdapter +from band.integrations.parlant.ports import reserve_server_ports from band.integrations.parlant.tools import create_parlant_tools setup_logging() @@ -124,7 +125,12 @@ async def main() -> None: if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") # Start Parlant server with OpenAI (requires OPENAI_API_KEY env var) - async with p.Server(nlp_service=p.NLPServices.openai) as server: + ports = reserve_server_ports() + async with p.Server( + port=ports.port, + tool_service_port=ports.tool_service_port, + nlp_service=p.NLPServices.openai, + ) as server: # Create Parlant tools INSIDE server context parlant_tools = create_parlant_tools() logger.info( diff --git a/examples/parlant/02_with_guidelines.py b/examples/parlant/02_with_guidelines.py index c4f13732a..e76ff1aba 100644 --- a/examples/parlant/02_with_guidelines.py +++ b/examples/parlant/02_with_guidelines.py @@ -29,6 +29,7 @@ from setup_logging import setup_logging from band import Agent from band.adapters import ParlantAdapter +from band.integrations.parlant.ports import reserve_server_ports from band.integrations.parlant.tools import create_parlant_tools setup_logging() @@ -143,7 +144,12 @@ async def main() -> None: if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") # Start Parlant server with OpenAI - async with p.Server(nlp_service=p.NLPServices.openai) as server: + ports = reserve_server_ports() + async with p.Server( + port=ports.port, + tool_service_port=ports.tool_service_port, + nlp_service=p.NLPServices.openai, + ) as server: # Create Parlant tools INSIDE server context parlant_tools = create_parlant_tools() logger.info( diff --git a/examples/parlant/03_support_agent.py b/examples/parlant/03_support_agent.py index d0b8bcbee..4e320044f 100644 --- a/examples/parlant/03_support_agent.py +++ b/examples/parlant/03_support_agent.py @@ -29,6 +29,7 @@ from setup_logging import setup_logging from band import Agent from band.adapters import ParlantAdapter +from band.integrations.parlant.ports import reserve_server_ports setup_logging() logger = logging.getLogger(__name__) @@ -107,7 +108,12 @@ async def main() -> None: if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") # Start Parlant server - async with p.Server(nlp_service=p.NLPServices.openai) as server: + ports = reserve_server_ports() + async with p.Server( + port=ports.port, + tool_service_port=ports.tool_service_port, + nlp_service=p.NLPServices.openai, + ) as server: # Create support agent with guidelines parlant_agent = await setup_support_agent(server) logger.info("Support agent created: %s", parlant_agent.id) diff --git a/examples/parlant/04_tom_agent.py b/examples/parlant/04_tom_agent.py index 6e2e057d9..9f571fb95 100644 --- a/examples/parlant/04_tom_agent.py +++ b/examples/parlant/04_tom_agent.py @@ -34,6 +34,7 @@ from setup_logging import setup_logging from band import Agent from band.adapters import ParlantAdapter +from band.integrations.parlant.ports import reserve_server_ports from band.integrations.parlant.tools import create_parlant_tools setup_logging() @@ -51,8 +52,13 @@ async def main() -> None: if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") - # Load Tom's credentials from agent_config.yaml - async with p.Server(nlp_service=p.NLPServices.openai) as server: + # Reserved rather than fixed, so Tom and Jerry can run side by side + ports = reserve_server_ports() + async with p.Server( + port=ports.port, + tool_service_port=ports.tool_service_port, + nlp_service=p.NLPServices.openai, + ) as server: parlant_tools = create_parlant_tools() # Create Parlant agent with Tom's personality diff --git a/examples/parlant/05_jerry_agent.py b/examples/parlant/05_jerry_agent.py index 251a750cc..bc280db69 100644 --- a/examples/parlant/05_jerry_agent.py +++ b/examples/parlant/05_jerry_agent.py @@ -34,6 +34,7 @@ from setup_logging import setup_logging from band import Agent from band.adapters import ParlantAdapter +from band.integrations.parlant.ports import reserve_server_ports from band.integrations.parlant.tools import create_parlant_tools setup_logging() @@ -51,8 +52,13 @@ async def main() -> None: if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") - # Load Jerry's credentials from agent_config.yaml - async with p.Server(nlp_service=p.NLPServices.openai) as server: + # Reserved rather than fixed, so Tom and Jerry can run side by side + ports = reserve_server_ports() + async with p.Server( + port=ports.port, + tool_service_port=ports.tool_service_port, + nlp_service=p.NLPServices.openai, + ) as server: parlant_tools = create_parlant_tools() # Create Parlant agent with Jerry's personality diff --git a/examples/parlant/README.md b/examples/parlant/README.md index 64f162da1..52264f4ed 100644 --- a/examples/parlant/README.md +++ b/examples/parlant/README.md @@ -30,6 +30,9 @@ uv sync --extra parlant The adapter uses the Parlant SDK directly - no separate HTTP server needed: +> Running more than one Parlant agent on this machine? Pass reserved ports instead of +> Parlant's fixed defaults — see [Running two agents locally](#running-two-agents-locally-tom-and-jerry). + ```python import parlant.sdk as p from band import Agent @@ -146,6 +149,40 @@ uv run python examples/parlant/03_support_agent.py > **Note:** The config loader looks for `agent_config.yaml` in the current working directory. Running from a subdirectory will cause a `FileNotFoundError`. +### Running two agents locally (Tom and Jerry) + +Each Parlant agent starts its own in-process server, and Parlant's tool-service +port defaults to a fixed `8818` — so a second agent on the same machine fails to +start while the first holds it, with a `SystemExit` out of uvicorn startup. The examples +call `reserve_server_ports()` to get a free pair from the OS instead, which lets +them run side by side: + +```bash +# terminal 1 +uv run python examples/parlant/04_tom_agent.py + +# terminal 2 (while Tom is still running) +uv run python examples/parlant/05_jerry_agent.py +``` + +Each process logs the pair it reserved, so a refused connection can be traced back +to a known port: + +``` +Parlant server ports: api=54231, tool_service=54232 +``` + +Only `tool_service` is actually bound here: these examples block in `Agent.run()` +inside the `async with` body, and Parlant starts its API/UI server on exit from +that block — which never happens. The `api` port is reserved anyway, so the server +has a free one if it ever does serve. + +> **Note:** Pass real port numbers, not `port=0`. Parlant formats the number it +> was given into URLs rather than reading it back off the bound socket, so `0` +> would register the tool service at `http://127.0.0.1:0` (breaking every tool +> call) and point the readiness poll at a port that never answers (hanging +> shutdown). + --- ## Adapter Options diff --git a/src/band/integrations/parlant/__init__.py b/src/band/integrations/parlant/__init__.py index 51ed41c2e..7cf884426 100644 --- a/src/band/integrations/parlant/__init__.py +++ b/src/band/integrations/parlant/__init__.py @@ -5,7 +5,8 @@ (https://github.com/emcie-co/parlant) for building guideline-based conversational AI agents. -Usage: +Usage (to run several agents on one host, pass ``reserve_server_ports()`` to +``p.Server`` instead of letting it take its fixed default ports): import parlant.sdk as p from band import Agent from band.adapters import ParlantAdapter @@ -29,4 +30,8 @@ await band_agent.run() """ -__all__: list[str] = [] +from __future__ import annotations + +from band.integrations.parlant.ports import ServerPorts, reserve_server_ports + +__all__ = ["ServerPorts", "reserve_server_ports"] diff --git a/src/band/integrations/parlant/ports.py b/src/band/integrations/parlant/ports.py new file mode 100644 index 000000000..fee83f425 --- /dev/null +++ b/src/band/integrations/parlant/ports.py @@ -0,0 +1,73 @@ +"""Reserve free ports for a Parlant ``Server`` so several can run side by side. + +Parlant has two fixed default ports: ``8818`` for the integrated tool service, bound +during ``Server.__aenter__``, and ``8800`` for the API/UI, bound only once the server +starts serving in ``__aexit__``. A second agent on the same host fails to start while +the first holds one. In an agent that stays inside the ``async with`` body — as the +examples do, blocking in ``Agent.run()`` — only the tool service is ever bound, so +that is the collision in practice. + +Asking the OS for a port with ``port=0`` does **not** work here: Parlant +string-formats the number it was given into URLs instead of reading it back off the +bound socket. The integrated tool service registers as ``http://127.0.0.1:0`` and +every ``@p.tool`` is routed to it over HTTP, so tool calls would dial port 0; the +readiness poll would likewise target ``http://localhost:0/healthz``, never succeed, +and hang shutdown. Parlant has to be handed real port numbers. + +So we reserve them ourselves: bind two sockets, read the ports the OS assigned, and +close them before handing the numbers to Parlant, which re-binds them itself. The +close-then-rebind gap is the standard ephemeral-port reservation race, and far +smaller than the collision risk of two fixed ports. +""" + +from __future__ import annotations + +import logging +import socket +from typing import NamedTuple + +logger = logging.getLogger(__name__) + +__all__ = ["ServerPorts", "reserve_server_ports"] + + +class ServerPorts(NamedTuple): + """A free port pair for ``p.Server(port=..., tool_service_port=...)``.""" + + port: int + tool_service_port: int + + +def reserve_server_ports() -> ServerPorts: + """Reserve two distinct loopback ports and log them. + + Both are reserved on ``127.0.0.1``, never on all interfaces: the tool service — + the port that actually collides — is bound by Parlant on ``127.0.0.1`` + explicitly, so a loopback reservation is exactly the guarantee it needs. The API + server instead binds the ``host`` given to ``p.Server``, all interfaces by + default, for which a loopback reservation is very strong but not airtight: a + port free here could in principle be held by another process on some other + interface. Reserving that one on all interfaces to close the gap would mean + briefly opening a socket to the network for a port we only ever hand back as an + integer, which is not a trade worth making — and in an agent that stays inside + the ``async with`` body the API port is never bound at all. + + Logged so that a refused connection or a stuck listener can be traced back to a + known pair — with the ports varying per run, there is otherwise nothing to match + against ``lsof`` output or a Parlant error. + """ + with ( + socket.socket(socket.AF_INET, socket.SOCK_STREAM) as api, + socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tool_service, + ): + # Both are held open at once so the OS cannot hand back the same port twice. + api.bind(("127.0.0.1", 0)) + tool_service.bind(("127.0.0.1", 0)) + ports = ServerPorts(api.getsockname()[1], tool_service.getsockname()[1]) + + logger.info( + "Parlant server ports: api=%d, tool_service=%d", + ports.port, + ports.tool_service_port, + ) + return ports diff --git a/tests/e2e/baseline/toolkit/parlant_server.py b/tests/e2e/baseline/toolkit/parlant_server.py index fc1c92ece..c7b43b72d 100644 --- a/tests/e2e/baseline/toolkit/parlant_server.py +++ b/tests/e2e/baseline/toolkit/parlant_server.py @@ -32,13 +32,14 @@ import asyncio import contextlib import logging -import socket from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Any import parlant.sdk as p +from band.integrations.parlant.ports import reserve_server_ports + logger = logging.getLogger(__name__) # Generous ceiling for uvicorn to bind + serve + answer its first /healthz at @@ -47,26 +48,6 @@ _READY_TIMEOUT_S = 120.0 -def _reserve_two_ports() -> tuple[int, int]: - """Reserve two distinct loopback ports from the OS, then release them. - - Parlant's default ports (8800/8818) collide under ``flaky`` reruns (the prior - server may not have released them yet) or two concurrent E2E sessions on one - host. Binding both sockets at once guarantees the OS hands back distinct ports; - we close them before passing the numbers to Parlant, which re-binds them itself. - The close->rebind gap is the standard ephemeral-port reservation race — far - smaller than the collision risk of two fixed ports (mirrors - ``mcp_server._reserve_socket``). - """ - with ( - socket.socket(socket.AF_INET, socket.SOCK_STREAM) as a, - socket.socket(socket.AF_INET, socket.SOCK_STREAM) as b, - ): - a.bind(("", 0)) - b.bind(("", 0)) - return a.getsockname()[1], b.getsockname()[1] - - @asynccontextmanager async def running_parlant_server( **server_kwargs: Any, @@ -82,7 +63,7 @@ async def running_parlant_server( defaults to Emcie's hosted service (``EMCIE_API_KEY``, which the env doesn't set). * ``port`` / ``tool_service_port`` default to freshly reserved ephemeral ports - (see ``_reserve_two_ports``) so reruns / concurrent sessions don't collide. + (see ``reserve_server_ports``) so reruns / concurrent sessions don't collide. The agent the caller builds on the yielded server runs against its in-process container; the HTTP server only ever comes up briefly during teardown, purely so @@ -96,9 +77,9 @@ async def running_parlant_server( """ server_kwargs.setdefault("nlp_service", p.NLPServices.openai) if "port" not in server_kwargs or "tool_service_port" not in server_kwargs: - port, tool_service_port = _reserve_two_ports() - server_kwargs.setdefault("port", port) - server_kwargs.setdefault("tool_service_port", tool_service_port) + ports = reserve_server_ports() + server_kwargs.setdefault("port", ports.port) + server_kwargs.setdefault("tool_service_port", ports.tool_service_port) server = p.Server(**server_kwargs) await server.__aenter__() # setup only: build the DI container, no serving yet diff --git a/tests/integrations/parlant/test_ports.py b/tests/integrations/parlant/test_ports.py new file mode 100644 index 000000000..4e4ded796 --- /dev/null +++ b/tests/integrations/parlant/test_ports.py @@ -0,0 +1,27 @@ +"""Tests for Parlant server port reservation.""" + +from __future__ import annotations + +import contextlib +import socket + +from band.integrations.parlant.ports import reserve_server_ports + + +def test_reserved_pair_is_two_distinct_ports() -> None: + """Parlant binds both, so a pair that repeats a number cannot start a server.""" + ports = reserve_server_ports() + + assert ports.port != ports.tool_service_port + + +def test_reserved_ports_are_free_for_the_caller_to_bind() -> None: + """The reservation is released — Parlant itself re-binds the numbers.""" + ports = reserve_server_ports() + + with contextlib.ExitStack() as stack: + for port in ports: + bound = stack.enter_context( + socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ) + bound.bind(("127.0.0.1", port))