fix(sdk): use ephemeral ports for Parlant servers - #341
Conversation
c54eb0c to
60f93d3
Compare
68fc7cb to
3533aff
Compare
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
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.
| async with p.Server(nlp_service=p.NLPServices.openai) as server: | ||
| async with p.Server( | ||
| port=0, | ||
| tool_service_port=0, |
There was a problem hiding this comment.
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):
tool_service_port=0: the built-in tool service registers asurl=f"http://{host}:{port}"→http://127.0.0.1:0, andkind == "sdk"resolves to an HTTPPluginClient. Every@p.toolis routed to that service, so tool calls dial port 0.port=0: the readiness poll targetshttp://localhost:0/healthzand swallowsConnectErrorin a 1s retry loop, soreadynever sets andfinally: await health_check_tasknever returns — Ctrl-C hangs.
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.
| async with p.Server(nlp_service=p.NLPServices.openai) as server: | ||
| async with p.Server( | ||
| port=0, | ||
| tool_service_port=0, |
There was a problem hiding this comment.
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.
| ### 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 |
There was a problem hiding this comment.
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.
| # 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", |
There was a problem hiding this comment.
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.
3533aff to
b3afbf7
Compare
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>
b3afbf7 to
07d1703
Compare
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>
What changed
All five Parlant examples (
01_basic_agent.py–05_jerry_agent.py) now start their in-process servers on ports reserved from the OS instead of Parlant's fixed defaults:reserve_server_ports()is new, insrc/band/integrations/parlant/ports.py. The baseline E2E toolkit already had this logic privately asparlant_server.py::_reserve_two_ports; it's lifted into the SDK so the examples (which can't import fromtests/) 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) duringServer.__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 withSystemExit: 1out 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:tool_service_port=0— the built-in tool service registers asurl=f"http://{host}:{port}"→http://127.0.0.1:0, andkind == "sdk"resolves to an HTTPPluginClient. Every@p.toolis routed there, so tool calls dial port 0 — includingband_send_message, i.e. the agent can't reply at all.port=0— the readiness poll targetshttp://localhost:0/healthzand swallowsConnectErrorin a 1s retry loop, soreadynever sets andfinally: await health_check_tasknever returns. Ctrl-C hangs.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:
Behavioural — asserted against Parlant's own
ServiceRegistrythat the reserved port reaches the registered URL:That health check is the exact call
port=0left retrying forever; shutdown returned cleanly rather than hanging.End-to-end — Tom and Jerry run concurrently against the live platform, on distinct pairs (
49791/49792and49801/49802), holding a real conversation. 92 and 80 tool-path sends respectively, zero connection errors:band_send_messageover HTTP to the plugin server is precisely whattool_service_port=0would 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
mainindependently or were superseded, so this is scoped to the port fix alone.The
opentelemetry-resourcedetector-gcpcommit has been dropped. It's no longer needed on two counts:mainalready declares that dependency (landed independently after this branch's base), and upstream has since published a stable1.14.0after the yanked1.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 stable1.14.0. ThepackagingCI job passes on this branch, which carries neither fix.Closes INT-530