Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion examples/parlant/01_basic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion examples/parlant/02_with_guidelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion examples/parlant/03_support_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions examples/parlant/04_tom_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions examples/parlant/05_jerry_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions examples/parlant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/band/integrations/parlant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
73 changes: 73 additions & 0 deletions src/band/integrations/parlant/ports.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 6 additions & 25 deletions tests/e2e/baseline/toolkit/parlant_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/integrations/parlant/test_ports.py
Original file line number Diff line number Diff line change
@@ -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))
Loading