Skip to content

fix(sdk): use ephemeral ports for Parlant servers - #341

Open
darvell-thenvoi wants to merge 4 commits into
mainfrom
fix/parlant-port-collision-INT-530
Open

fix(sdk): use ephemeral ports for Parlant servers#341
darvell-thenvoi wants to merge 4 commits into
mainfrom
fix/parlant-port-collision-INT-530

Conversation

@darvell-thenvoi

@darvell-thenvoi darvell-thenvoi commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

What changed

All five Parlant examples (01_basic_agent.py05_jerry_agent.py) now start their in-process servers on ports reserved from the OS instead of Parlant's fixed defaults:

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:
    ...

reserve_server_ports() is new, in src/band/integrations/parlant/ports.py. The baseline E2E toolkit already had this logic privately as parlant_server.py::_reserve_two_ports; it's lifted into the SDK so the examples (which can't import from tests/) and the toolkit share one definition, and the toolkit now imports it.

The README gains a "Running two agents locally (Tom and Jerry)" section.

Why

Parlant's integrated tool service binds a fixed default port (8818) during Server.__aenter__. Starting a second Parlant agent on the same machine fails while the first holds it — running Tom and Jerry side by side, the second dies with SystemExit: 1 out of uvicorn startup.

Why not port=0? Parlant doesn't support it: it string-formats the number it's given into URLs rather than reading it back off the bound socket. At the pinned v3.3.2:

Reserving instead: bind two sockets at once (so the OS can't return the same port twice), read the assigned numbers, close them, and hand Parlant real integers to re-bind. The close→rebind gap is the standard ephemeral-port reservation race — far smaller than the collision risk of two fixed ports.

Validation

Static:

uv run ruff check . && uv run ruff format --check .
uv run pyrefly check
uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/   # 4071 passed, 81 skipped

Behavioural — asserted against Parlant's own ServiceRegistry that the reserved port reaches the registered URL:

RESERVED   -> port=49751 tool_service_port=49752
REGISTERED -> http://127.0.0.1:49752          # not :0
CONNECTED  -> tool service is live on 49752
GET http://localhost:49751/healthz "HTTP/1.1 200 OK"

That health check is the exact call port=0 left retrying forever; shutdown returned cleanly rather than hanging.

End-to-end — Tom and Jerry run concurrently against the live platform, on distinct pairs (49791/49792 and 49801/49802), holding a real conversation. 92 and 80 tool-path sends respectively, zero connection errors:

Jerry: Quadruple-layer soufflé, @tom? Now you're speaking my language…
Tom: Keep those whiskers twitching, @jerry

band_send_message over HTTP to the plugin server is precisely what tool_service_port=0 would have broken.

Notes

Rebased onto current main, squashed to one commit.

Earlier revisions of this branch carried a large Parlant rewrite plus generic-runner/docs changes; those either landed on main independently or were superseded, so this is scoped to the port fix alone.

The opentelemetry-resourcedetector-gcp commit has been dropped. It's no longer needed on two counts: main already declares that dependency (landed independently after this branch's base), and upstream has since published a stable 1.14.0 after the yanked 1.13.0, so a fresh lockfile-free resolve of [dev]/[google_adk] succeeds without any declaration. Verified by building a wheel with the declaration removed and dry-running [dev] in a clean venv — resolves to stable 1.14.0. The packaging CI job passes on this branch, which carries neither fix.

Closes INT-530

@linear

linear Bot commented Jun 3, 2026

Copy link
Copy Markdown

INT-530

@darvell-thenvoi darvell-thenvoi changed the title Fix Parlant example port collisions Port Parlant validation into Band branch Jun 3, 2026
@darvell-thenvoi darvell-thenvoi changed the title Port Parlant validation into Band branch fix(sdk): port Parlant validation into Band branch Jun 3, 2026
@darvell-thenvoi darvell-thenvoi changed the title fix(sdk): port Parlant validation into Band branch fix(sdk): port Parlant validation and use ephemeral ports Jun 3, 2026
@darvell-thenvoi
darvell-thenvoi marked this pull request as ready for review June 3, 2026 19:41
@darvell-thenvoi darvell-thenvoi changed the title fix(sdk): port Parlant validation and use ephemeral ports fix(sdk): use ephemeral ports for Parlant servers Jun 3, 2026
Base automatically changed from rename-band-sdk-python to dev June 4, 2026 16:24
@darvell-thenvoi
darvell-thenvoi force-pushed the fix/parlant-port-collision-INT-530 branch from c54eb0c to 60f93d3 Compare June 4, 2026 20:47
Base automatically changed from dev to main June 22, 2026 10:58
@amit-gazal-thenvoi
amit-gazal-thenvoi force-pushed the fix/parlant-port-collision-INT-530 branch 2 times, most recently from 68fc7cb to 3533aff Compare July 23, 2026 13:50
@amit-gazal-thenvoi
amit-gazal-thenvoi requested review from a team and removed request for amit-gazal-thenvoi July 23, 2026 13:54

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dep commit looks right (one wording fix inline). The Parlant half needs rework: port=0 / tool_service_port=0 aren't supported by Parlant — it formats the port into URLs instead of reading the bound socket, so tool calls dial port 0 and shutdown hangs. Details inline. The dep commit is fine on its own — worth splitting it out so it can land now and unblock the packaging job.

Comment thread examples/parlant/01_basic_agent.py Outdated
async with p.Server(nlp_service=p.NLPServices.openai) as server:
async with p.Server(
port=0,
tool_service_port=0,

@AlexanderZ-Band AlexanderZ-Band Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

port=0 isn't supported by Parlant — it string-formats the port into URLs and never reads it back from the bound socket (all refs at our pinned v3.3.2):

The mechanism already exists: tests/e2e/baseline/toolkit/parlant_server.py::_reserve_two_ports reserves two real ephemeral ports, closes them, and passes the integers — "which Parlant re-binds itself". Suggest lifting it into src/band/integrations/parlant/ (examples can't import from tests/) so examples and the baseline toolkit share one definition, and logging the chosen ports — the fixed 8800 UI is otherwise unreachable.

Comment thread examples/parlant/04_tom_agent.py Outdated
async with p.Server(nlp_service=p.NLPServices.openai) as server:
async with p.Server(
port=0,
tool_service_port=0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concrete fallout of tool_service_port=0 here: the guideline below attaches parlant_tools and tells Tom to reply with band_send_message, but that call goes over HTTP to the built-in tool service registered at http://127.0.0.1:0. Tom can't reply at all — worth running Tom+Jerry through one actual reply before merging.

Comment thread examples/parlant/README.md Outdated
### Running two agents locally (Tom and Jerry)

Each Parlant agent starts its own in-process server. The examples pass
`port=0` and `tool_service_port=0` so the OS assigns free ports, which lets

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documents behaviour that doesn't hold: with tool_service_port=0 the two-agent run starts fine and then dies at the first tool call (see the note on 01_basic_agent.py). Needs updating once the ports come from a real reservation.

Comment thread pyproject.toml Outdated
# install excludes pre-releases by default and the one stable build (1.13.0)
# was yanked upstream, leaving no solution — so request it explicitly with a
# pre-release specifier to allow the alpha.
"opentelemetry-resourcedetector-gcp>=1.11.0a0",

@AlexanderZ-Band AlexanderZ-Band Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things on this line.

The floor is inert — only the a0 suffix does work (it satisfies uv's pre-release opt-in). Verified with uv pip install --dry-run "<wheel>[extra]" against a probe package:

>=1.0a0      -> resourcedetector-gcp==1.12.0a0
>=1.5.0a0    -> resourcedetector-gcp==1.12.0a0
>=1.11.0a0   -> resourcedetector-gcp==1.12.0a0

1.11.0a0 is tighter than anything upstream asks (opentelemetry-exporter-gcp-trace wants ==1.*,>=1.5.0dev0; google-adk wants >=1.9.0a0,<2), so it buys nothing and adds a future conflict surface. Prefer mirroring upstream: >=1.5.0a0.

Don't let anyone "fix" this by excluding the yank instead. With [tool.uv] constraint-dependencies = ["opentelemetry-resourcedetector-gcp!=1.13.0"], uv silently backtracks opentelemetry-exporter-gcp-trace to 1.4.0 rather than erroring — a wrong resolution with no signal. Declaring it here, as you did, is the only fix that travels with the published package.

The comment above needs rewording — it points the next reader at the wrong resolver. pip already resolves this: pip install --dry-run "google-adk>=1.0.0,<2" picks opentelemetry-resourcedetector-gcp-1.12.0a0, because PEP 440 permits pre-releases when the specifier itself carries one and google-adk requests >=1.9.0a0. The failure is uv-specific: uv accepts pre-releases only for a direct dependency with a pre-release specifier, or when all published versions are pre-releases — and the yanked stable 1.13.0 ("breaks imports") defeats that second rule.

@amit-gazal-thenvoi
amit-gazal-thenvoi force-pushed the fix/parlant-port-collision-INT-530 branch from 3533aff to b3afbf7 Compare July 26, 2026 11:01
Comment thread src/band/integrations/parlant/ports.py Fixed
Comment thread src/band/integrations/parlant/ports.py Fixed
Parlant's integrated tool service binds a fixed default port (8818) during
Server.__aenter__, so a second agent on the same host fails to start while the
first holds it — running Tom and Jerry side by side crashed the second with a
uvicorn SystemExit. (Parlant's other default, 8800 for the API/UI, only binds
once the server starts serving on exit from the `async with` body, which an
agent blocking in Agent.run() never reaches.)

Passing port=0 does not work: Parlant string-formats the number it is given
into URLs instead of reading it back off the bound socket. The integrated tool
service would register as http://127.0.0.1:0 and every @p.tool routes to it
over HTTP, so tool calls would dial port 0; the readiness poll would target
http://localhost:0/healthz, never connect, and hang shutdown.

Reserve the ports ourselves instead: bind two sockets, read what the OS
assigned, close them, and hand Parlant real numbers to re-bind. The baseline
E2E toolkit already did this privately; lift it to
band.integrations.parlant.ports so examples (which cannot import from tests/)
and the toolkit share one definition, and log the pair so a refused connection
can be traced back to a known port.

Verified against Parlant's own ServiceRegistry that the reserved tool-service
port reaches the registered URL, and by running Tom and Jerry concurrently on
distinct port pairs through a real exchange of band_send_message replies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amit-gazal-thenvoi
amit-gazal-thenvoi force-pushed the fix/parlant-port-collision-INT-530 branch from b3afbf7 to 07d1703 Compare July 26, 2026 11:24
amit-gazal-thenvoi and others added 3 commits July 26, 2026 14:33
Address review on the port-reservation change:

- Test the two invariants of `reserve_server_ports()` that can break: the
  pair holds two distinct numbers, and both are released for Parlant to
  re-bind.
- The Parlant quick-start snippets (examples README, package docstring)
  showed the fixed-default-port form the change exists to avoid; each now
  points at the reserved-ports guidance.
- The docstring said "loopback ports" while the code binds INADDR_ANY.
  Describe what it does and why that is the stricter reservation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code scanning flagged both reservation binds (py/bind-socket-all-network
-interfaces). Loopback is also the more accurate reservation: Parlant binds
the tool service — the port that actually collides — on 127.0.0.1
explicitly. Its API server binds all interfaces by default, so that half is
a very strong rather than airtight guarantee; the docstring says so, and
that port is never bound at all by an agent that stays in the `async with`
body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Observed exit is uvicorn's STARTUP_FAILURE (3), not 1, and the number is
uvicorn's to change. The signature a reader needs is the SystemExit out of
startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants