framework-agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,495 @@
1
+ """A2A server: agent-card + JSON-RPC message/send + /stream SSE.
2
+
3
+ Parity: src/a2a/index.ts (the A2AServer half; the client lives in a2a_client).
4
+
5
+ Pinned contract (each was a real bug in TS once — the port encodes the FIXED
6
+ behavior):
7
+
8
+ - Message bodies are ALWAYS parsed as JSON-RPC 2.0
9
+ `{jsonrpc, id, method, params: {message: {parts}, contextId}}`. The code
10
+ TRUSTS the POST body (TS parity): a body that is not a JSON object surfaces
11
+ as a JSON-RPC error, never a crash.
12
+ - A missing contextId is minted as `ctx-<timestamp36>`; the (possibly newly
13
+ minted) contextId is echoed inside the RESULT so multi-turn continuation
14
+ works.
15
+ - Agents are cached PER CONTEXT with LRU eviction (same contextId -> the SAME
16
+ agent instance; never one instance shared across contexts).
17
+ - /stream frames are exactly `{contextId, event}` | `{contextId, done, text}`
18
+ | `{contextId, error}` and terminate with a bare `[DONE]` frame.
19
+ - Client disconnects abort the run: a guard task watches the socket and
20
+ cancels the pump (mirror of TS `res.on("close")` + AbortController); since
21
+ the SSE headers are already out, the abort is reported as a final error
22
+ frame at best effort.
23
+ - Errors before any headers: JSON-RPC `{jsonrpc, id: null, error: {code:
24
+ -32603}}` — the pinned special case is an oversized body, answered 413.
25
+ After headers are sent (mid-stream failure) the error goes onto the wire
26
+ as an SSE error frame instead.
27
+ - HEAD on any route: headers only (Content-Length of the would-be body, no
28
+ body bytes) — Node suppresses HEAD bodies automatically, this port too.
29
+ - When a checkpointer is configured, runs execute with
30
+ `{checkpointer, threadId: contextId}` (durable HITL threads), passed only
31
+ if the agent's run/stream accepts a second argument.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import inspect
38
+ import json
39
+ import time
40
+ from http import HTTPStatus
41
+ from typing import Any, Awaitable, Callable, Optional, Protocol, Union, runtime_checkable
42
+
43
+ from .agent import Agent
44
+ from .errors import AgentError
45
+ from .types import ChatMessage
46
+
47
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
48
+ _MAX_HEADER_BYTES = 64 * 1024
49
+
50
+ # Reason phrases are status-specific (a 500 must never read "OK"): pinned
51
+ # phrases first, then the HTTPStatus registry, then a sane fallback.
52
+ _PHRASE: dict[int, str] = {
53
+ **{int(member): member.phrase for member in HTTPStatus},
54
+ 200: "OK",
55
+ 204: "No Content",
56
+ 400: "Bad Request",
57
+ 404: "Not Found",
58
+ 413: "Payload Too Large",
59
+ }
60
+
61
+ AgentFactory = Callable[[str], Union[Agent, Awaitable[Agent]]]
62
+
63
+
64
+ @runtime_checkable
65
+ class Checkpointer(Protocol):
66
+ """Durable threads: save/load/delete a context's message history."""
67
+
68
+ async def save(self, thread_id: str, messages: list[ChatMessage]) -> None: ...
69
+
70
+ async def load(self, thread_id: str) -> list[ChatMessage]: ...
71
+
72
+ async def delete(self, thread_id: str) -> None: ...
73
+
74
+
75
+ def _mint_context_id() -> str:
76
+ """`ctx-<timestamp36>` — TS parity (Date.now().toString(36))."""
77
+ value, digits = int(time.time() * 1000), ""
78
+ while value:
79
+ value, rem = divmod(value, 36)
80
+ digits = "0123456789abcdefghijklmnopqrstuvwxyz"[rem] + digits
81
+ return f"ctx-{digits or '0'}"
82
+
83
+
84
+ def _text_of_parts(parts: Any) -> str:
85
+ if not isinstance(parts, list):
86
+ return ""
87
+ out: list[str] = []
88
+ for part in parts:
89
+ if isinstance(part, str):
90
+ out.append(part)
91
+ elif isinstance(part, dict) and isinstance(part.get("text"), str):
92
+ out.append(part["text"])
93
+ return "".join(out)
94
+
95
+
96
+ def _as_rpc_dict(raw: Any) -> dict[str, Any]:
97
+ # The server TRUSTS the body (TS casts the parse result); a non-object
98
+ # behaves like an RPC object with every field undefined.
99
+ return raw if isinstance(raw, dict) else {}
100
+
101
+
102
+ def _message(err: BaseException) -> str:
103
+ return str(err) or err.__class__.__name__
104
+
105
+
106
+ def _accepts_run_options(bound_method: Callable[..., Any]) -> bool:
107
+ """True if run/stream accepts a second (run options) argument."""
108
+ try:
109
+ sig = inspect.signature(bound_method)
110
+ except (TypeError, ValueError): # pragma: no cover - exotic callables
111
+ return False
112
+ rest = list(sig.parameters.values())[1:] # skip the input argument
113
+ if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in rest):
114
+ return True
115
+ return any(
116
+ p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) for p in rest
117
+ )
118
+
119
+
120
+ class A2AServer:
121
+ """Serve a Framework agent over A2A (card + message/send + /stream)."""
122
+
123
+ def __init__(
124
+ self,
125
+ card: dict[str, Any],
126
+ agent_factory: AgentFactory,
127
+ max_contexts: int = 1000,
128
+ checkpointer: Optional[Checkpointer] = None,
129
+ cors_origins: Optional[list[str]] = None,
130
+ max_body_bytes: int = DEFAULT_MAX_BODY_BYTES,
131
+ ) -> None:
132
+ self.card = card
133
+ self.agent_factory = agent_factory
134
+ self.max_contexts = max(1, max_contexts)
135
+ self.checkpointer = checkpointer
136
+ self.cors_origins = list(cors_origins or [])
137
+ self.max_body_bytes = max_body_bytes
138
+ self._server: Optional[asyncio.AbstractServer] = None
139
+ # Plain dicts are insertion-ordered: this IS the LRU (reinsert on hit).
140
+ self._contexts: dict[str, Agent] = {}
141
+
142
+ # -- lifecycle ----------------------------------------------------------
143
+
144
+ async def start(self, host: str = "127.0.0.1", port: int = 0) -> int:
145
+ """Bind and return the real port (port 0 => ephemeral port)."""
146
+ self._server = await asyncio.start_server(self._handle_conn, host=host, port=port, limit=_MAX_HEADER_BYTES)
147
+ assert self._server.sockets is not None, "asyncio server always has a socket"
148
+ return self._server.sockets[0].getsockname()[1]
149
+
150
+ async def stop(self) -> None:
151
+ server, self._server = self._server, None
152
+ if server is not None:
153
+ server.close()
154
+ await server.wait_closed()
155
+ self._contexts.clear()
156
+
157
+ # -- context management --------------------------------------------------
158
+
159
+ async def _agent_for(self, context_id: str) -> Agent:
160
+ """Same contextId -> the SAME agent (LRU-retained); never shared across contexts."""
161
+ existing = self._contexts.get(context_id)
162
+ if existing is not None:
163
+ del self._contexts[context_id] # move-to-end
164
+ self._contexts[context_id] = existing
165
+ return existing
166
+ agent = self.agent_factory(context_id)
167
+ if inspect.isawaitable(agent):
168
+ # Known concurrency quirk, shared with the TS port: a COLD cache
169
+ # can double-build — two concurrent requests for the same unseen
170
+ # context both reach this await, each resolves the factory, and
171
+ # the later insert overwrites the first agent instance (its
172
+ # state/history is dropped). Documented, NOT changed to preserve
173
+ # TS semantics; a per-context in-flight build map is future work.
174
+ agent = await agent
175
+ self._contexts[context_id] = agent
176
+ while len(self._contexts) > self.max_contexts:
177
+ oldest = next(iter(self._contexts))
178
+ del self._contexts[oldest]
179
+ return agent
180
+
181
+ def _run_opts(self, context_id: str) -> dict[str, Any]:
182
+ if self.checkpointer is None:
183
+ return {}
184
+ return {"checkpointer": self.checkpointer, "threadId": context_id}
185
+
186
+ async def _run_agent(self, agent: Agent, context_id: str, text: str) -> Any:
187
+ opts = self._run_opts(context_id)
188
+ if opts and _accepts_run_options(agent.run):
189
+ return await agent.run(text, opts)
190
+ return await agent.run(text)
191
+
192
+ # -- HTTP plumbing ---------------------------------------------------------
193
+
194
+ def _cors_headers(self, origin: Optional[str]) -> dict[str, str]:
195
+ allowed = self.cors_origins
196
+ if not allowed or not origin:
197
+ return {}
198
+ if "*" not in allowed and origin not in allowed:
199
+ return {}
200
+ return {
201
+ "access-control-allow-origin": "*" if "*" in allowed else origin,
202
+ "access-control-allow-methods": "GET, POST, OPTIONS",
203
+ "access-control-allow-headers": "content-type, authorization",
204
+ }
205
+
206
+ async def _read_body(self, reader: asyncio.StreamReader, headers: dict[str, str]) -> bytes:
207
+ """Read the request body with a hard cap — no more than `max_body_bytes` is ever buffered."""
208
+ max_len = self.max_body_bytes
209
+ if "chunked" in headers.get("transfer-encoding", "").lower():
210
+ chunks: list[bytes] = []
211
+ total = 0
212
+ while True:
213
+ size_line = await reader.readline()
214
+ size = int(size_line.strip().split(b";")[0], 16)
215
+ if size == 0:
216
+ while True: # trailers end at a blank line (ignored)
217
+ line = await reader.readline()
218
+ if not line or line in (b"\r\n", b"\n"):
219
+ break
220
+ break
221
+ total += size
222
+ if total > max_len:
223
+ raise AgentError("A2A_BODY_TOO_LARGE", f"Request body exceeds {max_len} bytes", retryable=False)
224
+ chunks.append(await reader.readexactly(size))
225
+ await reader.readexactly(2) # chunk CRLF
226
+ return b"".join(chunks)
227
+ raw_length = headers.get("content-length")
228
+ length = int(raw_length) if raw_length is not None else 0
229
+ if length > max_len: # rejected before ANY byte of the body is read
230
+ raise AgentError("A2A_BODY_TOO_LARGE", f"Request body exceeds {max_len} bytes", retryable=False)
231
+ if length == 0:
232
+ return b""
233
+ return await reader.readexactly(length)
234
+
235
+ def _send(
236
+ self,
237
+ writer: asyncio.StreamWriter,
238
+ status: int,
239
+ body: bytes,
240
+ cors: dict[str, str],
241
+ extra_headers: Optional[dict[str, str]] = None,
242
+ content_type: str = "application/json",
243
+ with_content_length: bool = True,
244
+ send_body: bool = True,
245
+ ) -> None:
246
+ """Write a response. ``send_body=False`` (HEAD): headers only — the
247
+ Content-Length is still the WOULD-BE body's, but no body bytes go out
248
+ (Node suppresses HEAD bodies automatically; this port must do it)."""
249
+ headers = {"connection": "close", **cors, **(extra_headers or {})}
250
+ if with_content_length:
251
+ headers["content-length"] = str(len(body))
252
+ headers["content-type"] = content_type
253
+ head = f"HTTP/1.1 {status} {_PHRASE.get(status, 'Error' if status >= 400 else 'OK')}\r\n" + "".join(
254
+ f"{k}: {v}\r\n" for k, v in headers.items()
255
+ )
256
+ writer.write(head.encode("utf-8") + b"\r\n" + (body if send_body else b""))
257
+
258
+ def _send_json(self, writer: asyncio.StreamWriter, status: int, payload: Any, cors: dict[str, str]) -> None:
259
+ self._send(writer, status, json.dumps(payload).encode("utf-8"), cors)
260
+
261
+ def _send_head(self, writer: asyncio.StreamWriter, status: int, payload: Any, cors: dict[str, str]) -> None:
262
+ """HEAD response: the headers GET would have sent, body suppressed."""
263
+ self._send(writer, status, json.dumps(payload).encode("utf-8"), cors, send_body=False)
264
+
265
+ @staticmethod
266
+ def _frame(writer: asyncio.StreamWriter, payload: Any) -> None:
267
+ writer.write(f"data: {json.dumps(payload)}\n\n".encode("utf-8"))
268
+
269
+ @staticmethod
270
+ async def _frame_ok(writer: asyncio.StreamWriter, payload: Any) -> None:
271
+ """One SSE data frame (drains the socket)."""
272
+ A2AServer._frame(writer, payload)
273
+ await writer.drain()
274
+
275
+ async def _watch_disconnect(self, reader: asyncio.StreamReader, pump: "asyncio.Task[None]", finished: asyncio.Event) -> None:
276
+ """Mirror of TS `res.on("close")` + AbortController: the peer going
277
+ away cancels the pump (a disconnect ABORTS the run, never finishes it)."""
278
+ try:
279
+ while True:
280
+ data = await reader.read(4096) # extra bytes are stray; we wait for EOF
281
+ if not data:
282
+ break
283
+ except (ConnectionResetError, BrokenPipeError):
284
+ pass
285
+ if not finished.is_set():
286
+ pump.cancel()
287
+
288
+ async def _handle_conn(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
289
+ try:
290
+ await self._handle_one(reader, writer)
291
+ except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, asyncio.IncompleteReadError, OSError):
292
+ pass # socket died mid-response — the route handlers already did what they could
293
+ finally:
294
+ try:
295
+ writer.close()
296
+ await writer.wait_closed()
297
+ except (ConnectionResetError, BrokenPipeError, OSError): # pragma: no cover - torn-down peer
298
+ pass
299
+
300
+ async def _read_request(self, reader: asyncio.StreamReader) -> tuple[str, str, dict[str, str]]:
301
+ """(method, path, headers) — header names are lowercased."""
302
+ request_line = await reader.readline()
303
+ parts = request_line.split() if request_line else []
304
+ if len(parts) < 2:
305
+ raise asyncio.IncompleteReadError(request_line, None)
306
+ method = parts[0].decode("latin-1")
307
+ path = parts[1].decode("latin-1").split("?")[0]
308
+ headers: dict[str, str] = {}
309
+ while True:
310
+ line = await reader.readline()
311
+ if not line or line in (b"\r\n", b"\n"):
312
+ break
313
+ key, _, value = line.partition(b":")
314
+ headers[key.decode("latin-1").strip().lower()] = value.decode("latin-1").strip()
315
+ return method, path, headers
316
+
317
+ async def _handle_one(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
318
+ method, path, headers = await self._read_request(reader)
319
+ cors = self._cors_headers(headers.get("origin"))
320
+ # Mutated by _handle_stream the moment the SSE headers are out —
321
+ # BEFORE that, every failure is answered as a normal HTTP response.
322
+ sse: dict[str, bool] = {"started": False}
323
+
324
+ try:
325
+ if method == "OPTIONS":
326
+ self._send(writer, 204, b"", cors)
327
+ await writer.drain()
328
+ return
329
+ if method == "HEAD":
330
+ # HEAD mirrors GET semantics (headers only; POST-only routes
331
+ # are not GET-routable). Node suppresses HEAD bodies itself —
332
+ # this port sends the Content-Length of the would-be body but
333
+ # never any body bytes.
334
+ if path in ("/ok", "/health"):
335
+ self._send_head(writer, 200, {"ok": True}, cors)
336
+ elif path == "/.well-known/agent-card.json":
337
+ self._send_head(writer, 200, self.card, cors)
338
+ else:
339
+ self._send_head(writer, 404, {"error": "not found"}, cors)
340
+ await writer.drain()
341
+ return
342
+ if method == "GET" and path in ("/ok", "/health"):
343
+ self._send_json(writer, 200, {"ok": True}, cors)
344
+ await writer.drain()
345
+ return
346
+ if method == "GET" and path == "/.well-known/agent-card.json":
347
+ self._send_json(writer, 200, self.card, cors)
348
+ await writer.drain()
349
+ return
350
+ if method == "POST" and path in ("/", ""):
351
+ await self._handle_send(reader, writer, headers, cors)
352
+ return
353
+ if method == "POST" and path == "/stream":
354
+ await self._handle_stream(reader, writer, headers, cors, sse)
355
+ return
356
+ self._send_json(writer, 404, {"error": "not found"}, cors)
357
+ await writer.drain()
358
+ except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, OSError):
359
+ return # peer vanished mid-write; nothing more to send
360
+ except Exception as err: # noqa: BLE001 — every failure maps onto the wire
361
+ message = _message(err)
362
+ if sse["started"]:
363
+ # Mid-stream failure (TS "headersSent" branch): SSE headers are
364
+ # already out, so report the error as SSE frames.
365
+ try:
366
+ writer.write(f"data: {json.dumps({'error': message})}\n\n".encode() + b"data: [DONE]\n\n")
367
+ await writer.drain()
368
+ except (ConnectionResetError, BrokenPipeError, OSError):
369
+ pass # socket gone — nothing more to do
370
+ return
371
+ # Pre-header failure (body oversize / malformed JSON-RPC): a real
372
+ # HTTP response with a proper status line — never SSE frames.
373
+ status = 413 if isinstance(err, AgentError) and err.code == "A2A_BODY_TOO_LARGE" else 200
374
+ self._send_json(writer, status, {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": message}}, cors)
375
+ try:
376
+ await writer.drain()
377
+ except (ConnectionResetError, BrokenPipeError, OSError): # pragma: no cover
378
+ pass
379
+
380
+ async def _handle_send(
381
+ self,
382
+ reader: asyncio.StreamReader,
383
+ writer: asyncio.StreamWriter,
384
+ headers: dict[str, str],
385
+ cors: dict[str, str],
386
+ ) -> None:
387
+ # Trust-the-body parity: JSON parse errors bubble to the caller (-32603).
388
+ rpc = _as_rpc_dict(json.loads(await self._read_body(reader, headers)))
389
+ params = rpc.get("params") if isinstance(rpc.get("params"), dict) else {}
390
+ if rpc.get("method") != "message/send":
391
+ self._send_json(
392
+ writer,
393
+ 200,
394
+ {"jsonrpc": "2.0", "id": rpc.get("id"), "error": {"code": -32601, "message": f"Unknown method: {rpc.get('method')}"}},
395
+ cors,
396
+ )
397
+ await writer.drain()
398
+ return
399
+ context_id = params.get("contextId") or _mint_context_id()
400
+ text = _text_of_parts((params.get("message") or {}).get("parts") if isinstance(params.get("message"), dict) else None)
401
+ agent = await self._agent_for(context_id)
402
+ result = await self._run_agent(agent, context_id, text)
403
+ self._send_json(
404
+ writer,
405
+ 200,
406
+ {
407
+ "jsonrpc": "2.0",
408
+ "id": rpc.get("id"),
409
+ "result": {
410
+ "message": {"role": "agent", "parts": [{"kind": "text", "text": result.text}]},
411
+ "contextId": context_id,
412
+ },
413
+ },
414
+ cors,
415
+ )
416
+ await writer.drain()
417
+
418
+ async def _handle_stream(
419
+ self,
420
+ reader: asyncio.StreamReader,
421
+ writer: asyncio.StreamWriter,
422
+ headers: dict[str, str],
423
+ cors: dict[str, str],
424
+ sse: dict[str, bool],
425
+ ) -> None:
426
+ # Read + parse + validate the body BEFORE any SSE headers go out (TS
427
+ # readBody-before-writeHead): a failure here — oversized body (413),
428
+ # malformed JSON (JSON-RPC -32603) — is answered over HTTP with a real
429
+ # status line. Only after this point are errors SSE error frames (the
430
+ # headers are already on the wire, as in TS's "headersSent" branch).
431
+ rpc = _as_rpc_dict(json.loads(await self._read_body(reader, headers)))
432
+ params = rpc.get("params") if isinstance(rpc.get("params"), dict) else {}
433
+ context_id = params.get("contextId") or _mint_context_id()
434
+ text = _text_of_parts((params.get("message") or {}).get("parts") if isinstance(params.get("message"), dict) else None)
435
+ # SSE headers go out BEFORE the agent is resolved (TS parity): a
436
+ # failure from here on can only be an SSE error frame.
437
+ sse["started"] = True
438
+ self._send(
439
+ writer,
440
+ 200,
441
+ b"",
442
+ cors,
443
+ extra_headers={"cache-control": "no-cache", "connection": "keep-alive"},
444
+ content_type="text/event-stream",
445
+ with_content_length=False,
446
+ )
447
+ await writer.drain()
448
+
449
+ finished = asyncio.Event()
450
+
451
+ async def pump() -> None:
452
+ try:
453
+ agent = await self._agent_for(context_id)
454
+ opts = self._run_opts(context_id)
455
+ events = agent.stream(text, opts) if opts and _accepts_run_options(agent.stream) else agent.stream(text)
456
+ async for event in events:
457
+ # Every yielded event is forwarded as an {event} frame —
458
+ # including the agent's own done event (which carries the
459
+ # final text), EXACTLY like the TS server. The closing
460
+ # {done: true, text} frame then terminates the SSE body
461
+ # (the TS wire has both frames; clients may read either).
462
+ await self._frame_ok(writer, {"contextId": context_id, "event": event})
463
+ if isinstance(event, dict) and event.get("type") == "done":
464
+ await self._frame_ok(writer, {"contextId": context_id, "done": True, "text": str(event.get("text") or "")})
465
+ except asyncio.CancelledError:
466
+ # Guard-cancelled: the client went away mid-run. The socket is
467
+ # already gone, so the abort is reported at best effort.
468
+ try:
469
+ self._frame(writer, {"contextId": context_id, "error": "Run aborted (client disconnected)"})
470
+ except (ConnectionResetError, BrokenPipeError, OSError):
471
+ pass
472
+ raise
473
+ except Exception as err: # noqa: BLE001 — SSE error-frame parity
474
+ await self._frame_ok(writer, {"contextId": context_id, "error": _message(err)})
475
+
476
+ pump_task = asyncio.ensure_future(pump())
477
+ guard_task = asyncio.ensure_future(self._watch_disconnect(reader, pump_task, finished))
478
+ cancelled = False
479
+ try:
480
+ await pump_task
481
+ except asyncio.CancelledError:
482
+ cancelled = True # peer already gone — no frames would arrive
483
+ finally:
484
+ finished.set()
485
+ guard_task.cancel()
486
+ try:
487
+ await guard_task
488
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
489
+ pass
490
+ if not cancelled:
491
+ self._frame(writer, "[DONE]")
492
+ try:
493
+ await writer.drain()
494
+ except (ConnectionResetError, BrokenPipeError, OSError):
495
+ pass