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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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
|