agent_search_gateway.protocol
Migration-stable newline-delimited JSON socket protocol.
1"""Migration-stable newline-delimited JSON socket protocol.""" 2 3import asyncio 4import json 5from collections.abc import Callable, Mapping 6from contextlib import suppress 7from pathlib import Path 8from typing import TypeAlias 9 10from .errors import DaemonUnavailable, ErrorCode, ProtocolFailure 11from .models import ( 12 ErrorResponse, 13 KeywordSearchRequest, 14 LLMSearchRequest, 15 PaperSearchRequest, 16 Request, 17 Response, 18 ShutdownRequest, 19 SuccessResponse, 20 URLFetchRequest, 21) 22 23DecodedRequest: TypeAlias = Request | ErrorResponse 24RequestParser = Callable[[Mapping[str, object]], Request] 25 26_MAX_REQUEST_FRAME_BYTES = 1 << 20 27_MAX_RESPONSE_FRAME_BYTES = 8 << 20 28_RESPONSE_TIMEOUT_SECONDS = 300.0 29 30 31def _bad_request(message: str) -> ErrorResponse: 32 return ErrorResponse(ErrorCode.BAD_REQUEST, message) 33 34 35def _require_exact_keys(payload: Mapping[str, object], expected: set[str]) -> None: 36 if set(payload) != expected: 37 raise ValueError("request fields do not match schema") 38 39 40def _parse_keyword(payload: Mapping[str, object]) -> Request: 41 _require_exact_keys(payload, {"type", "query"}) 42 query = payload["query"] 43 if not isinstance(query, str): 44 raise ValueError("query must be a string") 45 return KeywordSearchRequest(query) 46 47 48def _parse_paper(payload: Mapping[str, object]) -> Request: 49 _require_exact_keys(payload, {"type", "query"}) 50 query = payload["query"] 51 if not isinstance(query, str): 52 raise ValueError("query must be a string") 53 return PaperSearchRequest(query) 54 55 56def _parse_llm(payload: Mapping[str, object]) -> Request: 57 keys = set(payload) 58 if keys not in ({"type", "prompt"}, {"type", "prompt", "scope"}): 59 raise ValueError("request fields do not match schema") 60 prompt = payload["prompt"] 61 if not isinstance(prompt, str): 62 raise ValueError("prompt must be a string") 63 scope = payload.get("scope", "web") 64 if scope == "web": 65 return LLMSearchRequest(prompt, "web") 66 if scope == "paper": 67 return LLMSearchRequest(prompt, "paper") 68 if scope == "all": 69 return LLMSearchRequest(prompt, "all") 70 raise ValueError("scope must be web, paper, or all") 71 72 73def _parse_fetch(payload: Mapping[str, object]) -> Request: 74 _require_exact_keys(payload, {"type", "url", "focus"}) 75 url = payload["url"] 76 focus = payload["focus"] 77 if not isinstance(url, str): 78 raise ValueError("url must be a string") 79 if focus is not None and not isinstance(focus, str): 80 raise ValueError("focus must be a string or null") 81 return URLFetchRequest(url, focus) 82 83 84def _parse_shutdown(payload: Mapping[str, object]) -> Request: 85 _require_exact_keys(payload, {"type"}) 86 return ShutdownRequest() 87 88 89_REQUEST_PARSERS: dict[str, RequestParser] = { 90 "keyword_search": _parse_keyword, 91 "paper_search": _parse_paper, 92 "llm_search": _parse_llm, 93 "url_fetch": _parse_fetch, 94 "shutdown": _parse_shutdown, 95} 96 97 98def decode_request_frame(frame: bytes) -> DecodedRequest: 99 try: 100 text = frame.decode("utf-8") 101 payload = json.loads(text) 102 except (UnicodeDecodeError, json.JSONDecodeError): 103 return _bad_request("Request must be valid UTF-8 JSON") 104 if not isinstance(payload, dict): 105 return _bad_request("Request must be a JSON object") 106 request_type = payload.get("type") 107 if not isinstance(request_type, str): 108 return _bad_request("Request type must be a string") 109 parser = _REQUEST_PARSERS.get(request_type) 110 if parser is None: 111 return _bad_request("Unknown request type") 112 try: 113 return parser(payload) 114 except (KeyError, ValueError): 115 return _bad_request("Request fields do not match schema") 116 117 118class NDJSONDecoder: 119 def __init__(self) -> None: 120 self._buffer = bytearray() 121 self._discarding_oversized_frame = False 122 123 def feed(self, data: bytes) -> list[DecodedRequest]: 124 decoded: list[DecodedRequest] = [] 125 remaining = data 126 while remaining: 127 if self._discarding_oversized_frame: 128 boundary = remaining.find(b"\n") 129 if boundary < 0: 130 return decoded 131 self._discarding_oversized_frame = False 132 remaining = remaining[boundary + 1 :] 133 continue 134 135 boundary = remaining.find(b"\n") 136 if boundary < 0: 137 capacity = _MAX_REQUEST_FRAME_BYTES - len(self._buffer) 138 if len(remaining) > capacity: 139 self._buffer.clear() 140 self._discarding_oversized_frame = True 141 decoded.append(_bad_request("Request frame is too large")) 142 return decoded 143 self._buffer.extend(remaining) 144 return decoded 145 146 if len(self._buffer) + boundary > _MAX_REQUEST_FRAME_BYTES: 147 self._buffer.clear() 148 decoded.append(_bad_request("Request frame is too large")) 149 remaining = remaining[boundary + 1 :] 150 continue 151 152 self._buffer.extend(remaining[:boundary]) 153 frame = bytes(self._buffer) 154 self._buffer.clear() 155 decoded.append(decode_request_frame(frame)) 156 remaining = remaining[boundary + 1 :] 157 return decoded 158 159 160def _request_payload(request: Request) -> dict[str, object]: 161 if isinstance(request, KeywordSearchRequest): 162 return {"type": "keyword_search", "query": request.query} 163 if isinstance(request, PaperSearchRequest): 164 return {"type": "paper_search", "query": request.query} 165 if isinstance(request, LLMSearchRequest): 166 payload: dict[str, object] = {"type": "llm_search", "prompt": request.prompt} 167 if request.scope != "web": 168 payload["scope"] = request.scope 169 return payload 170 if isinstance(request, URLFetchRequest): 171 return {"type": "url_fetch", "url": request.url, "focus": request.focus} 172 return {"type": "shutdown"} 173 174 175def _response_payload(response: Response) -> dict[str, object]: 176 if isinstance(response, SuccessResponse): 177 return {"ok": True, "text": response.text} 178 return {"ok": False, "error": response.error.value, "message": response.message} 179 180 181def _encode(payload: Mapping[str, object]) -> bytes: 182 return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n" 183 184 185def encode_request(request: Request) -> bytes: 186 return _encode(_request_payload(request)) 187 188 189def encode_response(response: Response) -> bytes: 190 return _encode(_response_payload(response)) 191 192 193def parse_response_frame(frame: bytes) -> Response: 194 try: 195 text = frame.decode("utf-8") 196 payload = json.loads(text) 197 except (UnicodeDecodeError, json.JSONDecodeError) as exc: 198 raise _protocol_error("Response must be valid UTF-8 JSON") from exc 199 if not isinstance(payload, dict) or not isinstance(payload.get("ok"), bool): 200 raise _protocol_error("Response must contain boolean ok") 201 if payload["ok"] is True: 202 if set(payload) != {"ok", "text"} or not isinstance(payload.get("text"), str): 203 raise _protocol_error("Success response fields do not match schema") 204 return SuccessResponse(payload["text"]) 205 if set(payload) != {"ok", "error", "message"}: 206 raise _protocol_error("Error response fields do not match schema") 207 error = payload.get("error") 208 message = payload.get("message") 209 if not isinstance(error, str) or not isinstance(message, str): 210 raise _protocol_error("Error response fields have invalid types") 211 try: 212 code = ErrorCode(error) 213 except ValueError as exc: 214 raise _protocol_error("Error response contains unknown error code") from exc 215 return ErrorResponse(code, message) 216 217 218def _protocol_error(message: str) -> ProtocolFailure: 219 return ProtocolFailure(ErrorCode.PROTOCOL_ERROR, message) 220 221 222async def send_request( 223 socket_path: Path, 224 request: Request, 225 *, 226 response_timeout_seconds: float = _RESPONSE_TIMEOUT_SECONDS, 227) -> Response: 228 try: 229 reader, writer = await asyncio.open_unix_connection( 230 path=socket_path, 231 limit=_MAX_RESPONSE_FRAME_BYTES, 232 ) 233 except (FileNotFoundError, ConnectionRefusedError) as exc: 234 raise DaemonUnavailable(str(socket_path)) from exc 235 236 try: 237 writer.write(encode_request(request)) 238 await writer.drain() 239 try: 240 line = await asyncio.wait_for(reader.readline(), timeout=response_timeout_seconds) 241 except TimeoutError as exc: 242 raise _protocol_error("Timed out waiting for daemon response") from exc 243 except ValueError as exc: 244 raise _protocol_error("Daemon response frame is too large") from exc 245 if not line or not line.endswith(b"\n"): 246 raise _protocol_error("Daemon response ended before newline") 247 return parse_response_frame(line[:-1]) 248 finally: 249 writer.close() 250 with suppress(ConnectionError, BrokenPipeError): 251 await writer.wait_closed()
DecodedRequest: TypeAlias =
agent_search_gateway.models.KeywordSearchRequest | agent_search_gateway.models.PaperSearchRequest | agent_search_gateway.models.LLMSearchRequest | agent_search_gateway.models.URLFetchRequest | agent_search_gateway.models.ShutdownRequest | agent_search_gateway.models.ErrorResponse
RequestParser =
collections.abc.Callable[[collections.abc.Mapping[str, object]], agent_search_gateway.models.KeywordSearchRequest | agent_search_gateway.models.PaperSearchRequest | agent_search_gateway.models.LLMSearchRequest | agent_search_gateway.models.URLFetchRequest | agent_search_gateway.models.ShutdownRequest]
def
decode_request_frame( frame: bytes) -> agent_search_gateway.models.KeywordSearchRequest | agent_search_gateway.models.PaperSearchRequest | agent_search_gateway.models.LLMSearchRequest | agent_search_gateway.models.URLFetchRequest | agent_search_gateway.models.ShutdownRequest | agent_search_gateway.models.ErrorResponse:
99def decode_request_frame(frame: bytes) -> DecodedRequest: 100 try: 101 text = frame.decode("utf-8") 102 payload = json.loads(text) 103 except (UnicodeDecodeError, json.JSONDecodeError): 104 return _bad_request("Request must be valid UTF-8 JSON") 105 if not isinstance(payload, dict): 106 return _bad_request("Request must be a JSON object") 107 request_type = payload.get("type") 108 if not isinstance(request_type, str): 109 return _bad_request("Request type must be a string") 110 parser = _REQUEST_PARSERS.get(request_type) 111 if parser is None: 112 return _bad_request("Unknown request type") 113 try: 114 return parser(payload) 115 except (KeyError, ValueError): 116 return _bad_request("Request fields do not match schema")
class
NDJSONDecoder:
119class NDJSONDecoder: 120 def __init__(self) -> None: 121 self._buffer = bytearray() 122 self._discarding_oversized_frame = False 123 124 def feed(self, data: bytes) -> list[DecodedRequest]: 125 decoded: list[DecodedRequest] = [] 126 remaining = data 127 while remaining: 128 if self._discarding_oversized_frame: 129 boundary = remaining.find(b"\n") 130 if boundary < 0: 131 return decoded 132 self._discarding_oversized_frame = False 133 remaining = remaining[boundary + 1 :] 134 continue 135 136 boundary = remaining.find(b"\n") 137 if boundary < 0: 138 capacity = _MAX_REQUEST_FRAME_BYTES - len(self._buffer) 139 if len(remaining) > capacity: 140 self._buffer.clear() 141 self._discarding_oversized_frame = True 142 decoded.append(_bad_request("Request frame is too large")) 143 return decoded 144 self._buffer.extend(remaining) 145 return decoded 146 147 if len(self._buffer) + boundary > _MAX_REQUEST_FRAME_BYTES: 148 self._buffer.clear() 149 decoded.append(_bad_request("Request frame is too large")) 150 remaining = remaining[boundary + 1 :] 151 continue 152 153 self._buffer.extend(remaining[:boundary]) 154 frame = bytes(self._buffer) 155 self._buffer.clear() 156 decoded.append(decode_request_frame(frame)) 157 remaining = remaining[boundary + 1 :] 158 return decoded
def
feed( self, data: bytes) -> list[agent_search_gateway.models.KeywordSearchRequest | agent_search_gateway.models.PaperSearchRequest | agent_search_gateway.models.LLMSearchRequest | agent_search_gateway.models.URLFetchRequest | agent_search_gateway.models.ShutdownRequest | agent_search_gateway.models.ErrorResponse]:
124 def feed(self, data: bytes) -> list[DecodedRequest]: 125 decoded: list[DecodedRequest] = [] 126 remaining = data 127 while remaining: 128 if self._discarding_oversized_frame: 129 boundary = remaining.find(b"\n") 130 if boundary < 0: 131 return decoded 132 self._discarding_oversized_frame = False 133 remaining = remaining[boundary + 1 :] 134 continue 135 136 boundary = remaining.find(b"\n") 137 if boundary < 0: 138 capacity = _MAX_REQUEST_FRAME_BYTES - len(self._buffer) 139 if len(remaining) > capacity: 140 self._buffer.clear() 141 self._discarding_oversized_frame = True 142 decoded.append(_bad_request("Request frame is too large")) 143 return decoded 144 self._buffer.extend(remaining) 145 return decoded 146 147 if len(self._buffer) + boundary > _MAX_REQUEST_FRAME_BYTES: 148 self._buffer.clear() 149 decoded.append(_bad_request("Request frame is too large")) 150 remaining = remaining[boundary + 1 :] 151 continue 152 153 self._buffer.extend(remaining[:boundary]) 154 frame = bytes(self._buffer) 155 self._buffer.clear() 156 decoded.append(decode_request_frame(frame)) 157 remaining = remaining[boundary + 1 :] 158 return decoded
def
encode_response( response: agent_search_gateway.models.SuccessResponse | agent_search_gateway.models.ErrorResponse) -> bytes:
def
parse_response_frame( frame: bytes) -> agent_search_gateway.models.SuccessResponse | agent_search_gateway.models.ErrorResponse:
194def parse_response_frame(frame: bytes) -> Response: 195 try: 196 text = frame.decode("utf-8") 197 payload = json.loads(text) 198 except (UnicodeDecodeError, json.JSONDecodeError) as exc: 199 raise _protocol_error("Response must be valid UTF-8 JSON") from exc 200 if not isinstance(payload, dict) or not isinstance(payload.get("ok"), bool): 201 raise _protocol_error("Response must contain boolean ok") 202 if payload["ok"] is True: 203 if set(payload) != {"ok", "text"} or not isinstance(payload.get("text"), str): 204 raise _protocol_error("Success response fields do not match schema") 205 return SuccessResponse(payload["text"]) 206 if set(payload) != {"ok", "error", "message"}: 207 raise _protocol_error("Error response fields do not match schema") 208 error = payload.get("error") 209 message = payload.get("message") 210 if not isinstance(error, str) or not isinstance(message, str): 211 raise _protocol_error("Error response fields have invalid types") 212 try: 213 code = ErrorCode(error) 214 except ValueError as exc: 215 raise _protocol_error("Error response contains unknown error code") from exc 216 return ErrorResponse(code, message)
async def
send_request( socket_path: pathlib.Path, request: agent_search_gateway.models.KeywordSearchRequest | agent_search_gateway.models.PaperSearchRequest | agent_search_gateway.models.LLMSearchRequest | agent_search_gateway.models.URLFetchRequest | agent_search_gateway.models.ShutdownRequest, *, response_timeout_seconds: float = 300.0) -> agent_search_gateway.models.SuccessResponse | agent_search_gateway.models.ErrorResponse:
223async def send_request( 224 socket_path: Path, 225 request: Request, 226 *, 227 response_timeout_seconds: float = _RESPONSE_TIMEOUT_SECONDS, 228) -> Response: 229 try: 230 reader, writer = await asyncio.open_unix_connection( 231 path=socket_path, 232 limit=_MAX_RESPONSE_FRAME_BYTES, 233 ) 234 except (FileNotFoundError, ConnectionRefusedError) as exc: 235 raise DaemonUnavailable(str(socket_path)) from exc 236 237 try: 238 writer.write(encode_request(request)) 239 await writer.drain() 240 try: 241 line = await asyncio.wait_for(reader.readline(), timeout=response_timeout_seconds) 242 except TimeoutError as exc: 243 raise _protocol_error("Timed out waiting for daemon response") from exc 244 except ValueError as exc: 245 raise _protocol_error("Daemon response frame is too large") from exc 246 if not line or not line.endswith(b"\n"): 247 raise _protocol_error("Daemon response ended before newline") 248 return parse_response_frame(line[:-1]) 249 finally: 250 writer.close() 251 with suppress(ConnectionError, BrokenPipeError): 252 await writer.wait_closed()