Python SDK: §6.4 mandatory leading ack#101
Conversation
Spec §6.4 was sharpened: agents MUST emit exactly one
`{"type":"status","data":"ack"}` chunk as the FIRST message on the
reply subject, before any work that introduces observable latency.
The ack confirms request receipt, resets the caller's §6.6 inactivity
timeout ahead of any warm-up gap, and makes the stream observable to
generic NATS tooling (`nats req --wait-for-empty`).
All Python agents flow through `AgentService._on_prompt_request`, so
emitting from the SDK guarantees compliance without per-agent code
changes. Ack is published after the envelope decode (so a malformed
body still gets a 400 with no spurious ack) and before handler
invocation. The existing keepalive loop continues to emit periodic
acks during slow handlers — broker accepts multiple.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New test_leading_ack_e2e.py covers the §6.4 invariant from two angles: * happy path — first frame on the reply subject is `status=ack`, second is the handler's response, third is the §6.5 terminator. Locks the JSON shape so a future encode_chunk refactor can't silently drift. * malformed envelope — the 400 path runs before any ack would emit, so the wire is exactly `error(400) → terminator`. Pins down that the ack lives after decode validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SDK now auto-emits a `status=ack` chunk as the first frame on every reply subject, so existing tests that asserted strict ResponseChunk-only streams or specific chunk counts no longer hold. Updates: * test_echo_e2e / test_attachments_e2e / test_reference_agent_e2e — accept StatusChunk in the iterator, filter ResponseChunks for the count + content assertions, and pin down that the first chunk is the §6.4 leading ack. * test_query_e2e — filter StatusChunks out before the positional `first/second/third` unpack. * test_keepalive_emits_ack_during_slow_handler — bump expected ack floor from ≥1 to ≥2 (leading + at least one periodic keep-alive). * test_keepalive_disabled_emits_no_ack → renamed to …_emits_only_leading_ack: keep-alive disabled still yields the leading ack unconditionally; assert exactly one ack. * test_keepalive_skips_ack_for_fast_handler → renamed to …_emits_only_leading_ack_for_fast_handler: same invariant. * test_handler_exception_emits_error_then_terminator — extend the wire-shape assertion from 2 frames (error + terminator) to 3 (ack + error + terminator) and verify the leading-ack payload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the agent-sdk auto-emitting a §6.4 leading ack as the first frame on the reply subject, the REPL's "first chunk wins" spinner trigger would stop on the ack — leaving a visible silent gap before the agent's text arrives. Tweak the trigger to fire on the first ResponseChunk (or Query), not the first StatusChunk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* agent-sdk/python/CHANGELOG.md — [Unreleased] entry under Changed describing the unconditional leading-ack emission and the test surface update; notes that pre-existing Python agents become spec-compliant on upgrade. * client-sdk/python/CHANGELOG.md — [Unreleased] Notes entry flagging the spec §6.4 clarification and confirming the receive side is already wired correctly; mentions the 06-chat spinner tweak. * client-sdk/python/CLAUDE.md — new 2026-05-11 alignment milestone summarising the SDK chokepoint fix, the per-flag interaction with keepalive, and the test rewrites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull the "stop the spinner on first non-status chunk" check out of the per-branch isinstance arms into a single check at the top of the loop. Same behaviour, but trims a branch off `_run_turn` and gets it back under ruff's PLR0912 limit of 12 branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @renerocksai's task in 4m 8s —— View job PR Review: Python SDK §6.4 mandatory leading ack
SummaryImplementation is correct and the approach is sound. The leading ack is placed at exactly the right point in Finding 1 —
|
| # Note: the §6.4-MUST *leading* ack is emitted unconditionally by | ||
| # ``_on_prompt_request`` before the handler runs — it is independent of | ||
| # this keep-alive cadence, and disabling keep-alive does NOT disable the | ||
| # leading ack. |
There was a problem hiding this comment.
This comment correctly documents the invariant at the module level, but the class docstring for keepalive_interval_s (a few lines further down) says only "Pass None to disable" — without mentioning that the §6.4 leading ack still fires unconditionally. An agent author reading help(AgentService) or an IDE hover will get an incomplete picture. Worth adding a note there too, e.g. "Pass None to disable the periodic cadence; the §6.4 leading ack is emitted unconditionally regardless."
Three follow-ups from PR review (reviewer bot Finding 1 + post-PR independent audit): * client-sdk test_interop_e2e — `test_python_client_prompts_ts_reference_agent` asserted `isinstance(msg, ResponseChunk)` on every chunk in the TS reference agent's stream. Passes today (pre-TS-fix) but would fail the moment Mario's parallel `sdk-mandatory-initial-ack` branch lands and the TS reference agent starts emitting the §6.4 leading ack. Accept `StatusChunk` in the iterator and filter ResponseChunks for the content assertion so this test stays green across the cross-SDK rollout window. * agent-sdk test_keepalive — `test_keepalive_emits_only_leading_ack_for_fast_handler` counted acks but didn't assert the ack was the FIRST chunk under keep-alive-enabled. Pin `received[0]` to mirror the `_emits_only_leading_ack` (keep-alive-disabled) sibling so a regression that emits the ack AFTER a response chunk can't slip through the count assertion. * agent-sdk test_error_completion — Finding 1: the 500-path test was refactored to use `_collect_replies` but the 400-path test still inlined polling with a `len(collected) >= 2` guard that would mask any future spec extension. Extend `_collect_replies` to accept an optional `payload` and consolidate both tests on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three doc-drift fixes for the §6.4 sharpening (reviewer bot Finding 2
+ post-PR independent audit):
* agent-sdk/python/.../service.py — AgentService class docstring for
`keepalive_interval_s=None` said only "Pass None to disable" without
noting the §6.4 leading ack still fires unconditionally. An agent
author reading `help(AgentService)` or an IDE hover would expect
zero acks; reword to make the leading-vs-periodic distinction
explicit.
* client-sdk/python/docs/protocol-mapping.md — parenthetical
"(§6.4 status chunks are at the agent's discretion)" is now stale
for the leading ack. Reword to "periodic mid-stream cadence is
discretionary; leading ack is mandatory under v0.3 §6.4".
* README.md — characterised `{"type":"status","data":"ack"}` as
"for keep-alive" only; mention that it's also the mandatory §6.4
leading chunk so a first-time reader of the wire shape gets the
full story.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@claude, please review again — addressed both of your findings plus three more from a post-PR audit. Your Finding 1 (consolidate Your Finding 2 ( Three additional fixes from the audit:
Two commits on top of the original five:
Pre-push gate green on both SDKs: ruff (no-cache) ✓ · ruff format ✓ · mypy (no-incremental) ✓ · pytest 40 ✓ (agent-sdk) · pytest 212 ✓ (client-sdk, incl. interop). |
|
Claude finished @renerocksai's task in 1m 36s —— View job Re-review: Python SDK §6.4 mandatory leading ack
All five items are correctly resolved. Detailed notes follow. Finding 1 —
|
Two follow-ups to close out the post-review audit:
* test_prompt_emits_leading_ack — add a 500 ms latency budget on the
ack's arrival after the request is published. The §6.4 rationale
("ack precedes the §6.6 inactivity-timeout-relevant warm-up gap")
isn't enforced by ordering alone — a regression that wedged
synchronous work between decode and the ack `await` would still
produce ack-first wire order but defeat the spec invariant. The
bound is generous (~500× headroom over the ~1 ms healthy local-NATS
round-trip) so CI flake risk is negligible. Evidence written to
`ack-latency.json` alongside `frames.jsonl`.
* test_malformed_prompt_no_ack_before_400 — reviewer-bot minor
observation: the De Morgan form
`not isinstance(...) or .status != "ack"` is awkward to read. Pull
the `is_leading_ack` predicate out into a named local, and extend
the surrounding comment to make clear that the `len == 2` +
error-code header pair already pins the §6.4 invariant; the
explicit ack check is documentation-grade belt-and-braces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@claude, please review again — one more follow-up commit ( Latency budget on the leading ack (independent audit gap). Your minor observation ( Pre-push gate green on both SDKs: ruff (no-cache) ✓ · ruff format ✓ · mypy (no-incremental) ✓ · pytest 40 ✓ (agent-sdk) · pytest 212 ✓ (client-sdk). |
|
Claude finished @renerocksai's task in 1m 27s —— View job Re-review: Python SDK §6.4 mandatory leading ack (commit
|
|
Alright, as signaled in OOB comms, I'm going to merge this on the Python side. #100 also provides the §6.4 leading-ack fix, for both TypeScript and Python SDKs. After this PR has been merged, @M64GitHub amends #100 to drop all |
Spec §6.4 was tightened to require an immediate
`{"type":"status","data":"ack"}` chunk as the first message on the
reply subject, before any latency-inducing handler work
(nats-agent-sdk-docs@b1c6972). The Python SDK adopted this in PR #101
(merged as 0.4.0); this commit brings `@synadia-ai/agent-service`
into parity.
Change: `AgentService.#dispatchPrompt` publishes
`encodeChunk({type:"status",status:"ack"})` synchronously after a
successful envelope decode and before invoking the user-supplied
handler. The pre-existing periodic keep-alive interval is retained
for §6.6 inactivity-timer defense — the spec text no longer mentions
it but the wire shape remains valid; `keepaliveIntervalS` now
controls only the periodic cadence, not the leading ack.
Added integration test pinning the eager behavior: a handler that
blocks on a promise, the test asserts the ack arrives before the
handler is released. try/finally around the pre-release assertions
makes a failed expectation reliably unblock the suspended handler
so the test runner doesn't hang.
Wire-compatible — callers already decode arbitrary `status` chunks
via `@synadia-ai/agents`. Brings the TS reference agent and
`agents/open-agent` into compliance automatically. The other
agent harnesses (pi, openclaw, claude-code-headless, pi-headless)
already comply via their own pre-handler ack publishes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec §6.4 was tightened to require an immediate
`{"type":"status","data":"ack"}` chunk as the first message on the
reply subject, before any latency-inducing handler work
(nats-agent-sdk-docs@b1c6972). The Python SDK adopted this in PR #101
(merged as 0.4.0); this commit brings `@synadia-ai/agent-service`
into parity.
Change: `AgentService.#dispatchPrompt` publishes
`encodeChunk({type:"status",status:"ack"})` synchronously after a
successful envelope decode and before invoking the user-supplied
handler. The pre-existing periodic keep-alive interval is retained
for §6.6 inactivity-timer defense — the spec text no longer mentions
it but the wire shape remains valid; `keepaliveIntervalS` now
controls only the periodic cadence, not the leading ack.
Added integration test pinning the eager behavior: a handler that
blocks on a promise, the test asserts the ack arrives before the
handler is released. try/finally around the pre-release assertions
makes a failed expectation reliably unblock the suspended handler
so the test runner doesn't hang.
Wire-compatible — callers already decode arbitrary `status` chunks
via `@synadia-ai/agents`. Brings the TS reference agent and
`agents/open-agent` into compliance automatically. The other
agent harnesses (pi, openclaw, claude-code-headless, pi-headless)
already comply via their own pre-handler ack publishes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
{"type":"status","data":"ack"}chunk as the first message on the reply subject, before anyresponse/querychunk and before any work that introduces observable latency. The ack resets the caller's §6.6 inactivity timeout ahead of any warm-up gap and makes the stream observable to generic NATS tooling (nats req --wait-for-empty).AgentService._on_prompt_requestnow publishes the leading ack unconditionally after a successful envelope decode and before invoking the user handler. Every Python agent in the repo (reference agent,demo_echo, in-tree test handlers) becomes spec-compliant on upgrade with no per-agent code change.keepalive_interval_s— passingNoneonly disables the periodic keep-alive cadence. Malformed envelopes still produceerror(400) → terminatorwith no spurious ack (ack lives after decode validation).Changes
service.pyinserting the ack between decode and handler; one comment-block tweak documenting the relationship to keep-alive.test_leading_ack_e2e.pycovers the wire-shape ordering and the no-ack-before-400 invariant. Existing tests rewritten for the new invariant — see commit-by-commit history.06-chat.pyspinner trigger now waits for the firstResponseChunk/Queryrather than the first chunk overall, so the "thinking…" spinner keeps spinning through the leading ack instead of stopping prematurely.CHANGELOG.mdentries on both packages + a 2026-05-11 alignment milestone inclient-sdk/python/CLAUDE.md.Pairing with TS
This is the Python half. The TS half is being landed in parallel by Mario (branch
sdk-mandatory-initial-ack). The client side of either SDK already tolerates ack absence, so the cross-SDK interop test stays green throughout the rollout — there's a brief asymmetry on the wire while only one SDK has shipped, but no regression.Test plan
cd agent-sdk/python && uv run ruff check --no-cache .cd agent-sdk/python && uv run ruff format --check .cd agent-sdk/python && uv run mypy --no-incremental src tests examplescd agent-sdk/python && uv run pytest -v— 40 passedcd client-sdk/python && uv run ruff check --no-cache .cd client-sdk/python && uv run mypy --no-incremental src tests examplescd client-sdk/python && uv run pytest -v— 212 passed (includingtests/test_interop_e2e.pyagainst the unchanged TS reference agent — confirms the client receive side tolerates ack absence in the asymmetric window before the TS fix lands)agent-sdk/python/tests/_evidence/tests__test_leading_ack_e2e.py__test_prompt_emits_leading_ack/frames.jsonlshowsstatus=ackfirst, then the handler's response, then the §6.5 terminator.🤖 Generated with Claude Code