@zleap-ai/dsh-sag 0.1.0

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 (75) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/README.zh.md +58 -0
  5. package/THIRD_PARTY_NOTICES +671 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/embedded.md +61 -0
  8. package/lib/brand.d.ts +13 -0
  9. package/lib/brand.js +15 -0
  10. package/lib/cli/runtime.d.ts +40 -0
  11. package/lib/cli/runtime.js +121 -0
  12. package/lib/cli.d.ts +21 -0
  13. package/lib/cli.js +265 -0
  14. package/lib/config.d.ts +60 -0
  15. package/lib/config.js +120 -0
  16. package/lib/connection/descriptor.d.ts +4 -0
  17. package/lib/connection/descriptor.js +66 -0
  18. package/lib/connection/discovery.d.ts +40 -0
  19. package/lib/connection/discovery.js +139 -0
  20. package/lib/connection/guidance.d.ts +7 -0
  21. package/lib/connection/guidance.js +7 -0
  22. package/lib/connection/manager.d.ts +67 -0
  23. package/lib/connection/manager.js +273 -0
  24. package/lib/connection/store.d.ts +42 -0
  25. package/lib/connection/store.js +164 -0
  26. package/lib/connection/types.d.ts +35 -0
  27. package/lib/connection/types.js +2 -0
  28. package/lib/dsh-sag-cli.js +32202 -0
  29. package/lib/index.d.ts +11 -0
  30. package/lib/index.js +68 -0
  31. package/lib/local/api-client.d.ts +157 -0
  32. package/lib/local/api-client.js +338 -0
  33. package/lib/local/gateway.d.ts +43 -0
  34. package/lib/local/gateway.js +34 -0
  35. package/lib/local/mcp-probe.d.ts +27 -0
  36. package/lib/local/mcp-probe.js +92 -0
  37. package/lib/presentation.d.ts +8 -0
  38. package/lib/presentation.js +7 -0
  39. package/lib/runtime/client.d.ts +22 -0
  40. package/lib/runtime/client.js +117 -0
  41. package/lib/runtime/protocol.d.ts +113 -0
  42. package/lib/runtime/protocol.js +118 -0
  43. package/lib/runtime/supervisor.d.ts +30 -0
  44. package/lib/runtime/supervisor.js +107 -0
  45. package/lib/tools/documents.d.ts +8 -0
  46. package/lib/tools/documents.js +134 -0
  47. package/lib/tools/ingest.d.ts +5 -0
  48. package/lib/tools/ingest.js +35 -0
  49. package/lib/tools/local.d.ts +40 -0
  50. package/lib/tools/local.js +111 -0
  51. package/lib/tools/output.d.ts +96 -0
  52. package/lib/tools/output.js +67 -0
  53. package/lib/tools/read.d.ts +6 -0
  54. package/lib/tools/read.js +84 -0
  55. package/lib/tools/search.d.ts +6 -0
  56. package/lib/tools/search.js +107 -0
  57. package/lib/tools/sources.d.ts +5 -0
  58. package/lib/tools/sources.js +61 -0
  59. package/lib/tools/status.d.ts +5 -0
  60. package/lib/tools/status.js +28 -0
  61. package/lib/tools/upload.d.ts +6 -0
  62. package/lib/tools/upload.js +68 -0
  63. package/package.json +96 -0
  64. package/runtime/pyproject.toml +29 -0
  65. package/runtime/src/dsh_sag_runtime/__init__.py +3 -0
  66. package/runtime/src/dsh_sag_runtime/__main__.py +80 -0
  67. package/runtime/src/dsh_sag_runtime/engines.py +139 -0
  68. package/runtime/src/dsh_sag_runtime/errors.py +47 -0
  69. package/runtime/src/dsh_sag_runtime/evidence.py +53 -0
  70. package/runtime/src/dsh_sag_runtime/protocol.py +161 -0
  71. package/runtime/src/dsh_sag_runtime/read.py +61 -0
  72. package/runtime/src/dsh_sag_runtime/search.py +76 -0
  73. package/runtime/src/dsh_sag_runtime/server.py +136 -0
  74. package/runtime/uv.lock +2310 -0
  75. package/scripts/setup-runtime.mjs +47 -0
@@ -0,0 +1,47 @@
1
+ """Safe projection from runtime failures to JSON-RPC errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from zleap.sag import SagError
8
+
9
+ from .protocol import RpcErrorData
10
+
11
+ _SENSITIVE_PARTS = ("api_key", "apikey", "authorization", "password", "secret", "token")
12
+
13
+
14
+ def _safe_details(value: Any, *, depth: int = 0) -> Any:
15
+ if depth > 4:
16
+ return "[truncated]"
17
+ if value is None or isinstance(value, bool | int | float):
18
+ return value
19
+ if isinstance(value, str):
20
+ return value[:1000]
21
+ if isinstance(value, list | tuple):
22
+ return [_safe_details(item, depth=depth + 1) for item in value[:50]]
23
+ if isinstance(value, dict):
24
+ return {
25
+ str(key)[:100]: _safe_details(item, depth=depth + 1)
26
+ for key, item in list(value.items())[:50]
27
+ if not any(part in str(key).lower() for part in _SENSITIVE_PARTS)
28
+ }
29
+ return str(value)[:1000]
30
+
31
+
32
+ def safe_error_data(error: BaseException) -> RpcErrorData:
33
+ """Return only caller-safe SAG diagnostics; never project causes or tracebacks."""
34
+ if isinstance(error, SagError):
35
+ return RpcErrorData(
36
+ code=error.code,
37
+ operation=error.operation,
38
+ stage=error.stage,
39
+ retryable=error.retryable,
40
+ provider=error.provider,
41
+ itemId=error.item_id,
42
+ message=error.message,
43
+ details=_safe_details(error.details) if error.details else None,
44
+ )
45
+ if isinstance(error, ValueError):
46
+ return RpcErrorData(code="INVALID_ARGUMENT", retryable=False, message=str(error)[:1000])
47
+ return RpcErrorData(code="INTERNAL_ERROR", retryable=False, message="SAG runtime request failed")
@@ -0,0 +1,53 @@
1
+ """Opaque, deterministic evidence references shared by search and read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class EvidenceLocator:
12
+ namespace_id: str
13
+ source_id: str
14
+ chunk_id: str
15
+
16
+
17
+ class EvidenceRefCodec:
18
+ """Encode locators and enforce namespace admission while decoding."""
19
+
20
+ def __init__(self, namespaces: set[str] | frozenset[str]) -> None:
21
+ self._namespaces = frozenset(namespaces)
22
+
23
+ def encode(self, locator: EvidenceLocator) -> str:
24
+ if locator.namespace_id not in self._namespaces:
25
+ raise ValueError("evidence namespace is not configured")
26
+ raw = json.dumps(
27
+ {"c": locator.chunk_id, "n": locator.namespace_id, "s": locator.source_id, "v": 1},
28
+ ensure_ascii=False,
29
+ separators=(",", ":"),
30
+ sort_keys=True,
31
+ ).encode()
32
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
33
+
34
+ def encode_from_parts(self, namespace_id: str, source_id: str, chunk_id: str) -> str:
35
+ """Encode one locator without exposing its wire keys to callers."""
36
+ return self.encode(EvidenceLocator(namespace_id, source_id, chunk_id))
37
+
38
+ def decode(self, value: str) -> EvidenceLocator:
39
+ try:
40
+ if not value or any(character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" for character in value):
41
+ raise ValueError
42
+ padded = value + "=" * (-len(value) % 4)
43
+ raw = base64.b64decode(padded, altchars=b"-_", validate=True)
44
+ payload = json.loads(raw)
45
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
46
+ raise ValueError("invalid evidence reference") from error
47
+ if not isinstance(payload, dict) or set(payload) != {"v", "n", "s", "c"} or payload.get("v") != 1:
48
+ raise ValueError("invalid evidence reference")
49
+ if not all(isinstance(payload.get(key), str) and payload[key] for key in ("n", "s", "c")):
50
+ raise ValueError("invalid evidence reference")
51
+ if payload["n"] not in self._namespaces:
52
+ raise ValueError("evidence namespace is not configured")
53
+ return EvidenceLocator(payload["n"], payload["s"], payload["c"])
@@ -0,0 +1,161 @@
1
+ """Versioned JSON-RPC 2.0 wire messages used over stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Literal, TypeAlias
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+
10
+ RPC_PROTOCOL_VERSION = "1.0"
11
+ REQUIRED_ENGINE_VERSION = "0.10.0"
12
+ MAX_FRAME_BYTES = 8 * 1024 * 1024
13
+
14
+
15
+ class WireModel(BaseModel):
16
+ """Immutable strict wire model with camel-case serialization aliases."""
17
+
18
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
19
+
20
+
21
+ class InitializeParams(WireModel):
22
+ protocol_version: Literal["1.0"] = Field(alias="protocolVersion")
23
+
24
+
25
+ class SearchParams(WireModel):
26
+ query: str = Field(min_length=1, max_length=32_000)
27
+ namespaces: tuple[str, ...] = Field(min_length=1)
28
+ mode: Literal["fast", "precise"]
29
+ limit: int = Field(ge=1, le=50)
30
+
31
+ @field_validator("namespaces")
32
+ @classmethod
33
+ def validate_namespaces(cls, values: tuple[str, ...]) -> tuple[str, ...]:
34
+ if any(not value or len(value) > 36 for value in values):
35
+ raise ValueError("namespace ids must contain 1 to 36 characters")
36
+ return values
37
+
38
+
39
+ class ReadParams(WireModel):
40
+ evidence_ref: str = Field(alias="evidenceRef", min_length=1, max_length=4096)
41
+ offset: int = Field(ge=0)
42
+ max_chars: int = Field(alias="maxChars", ge=1, le=200_000)
43
+ include_events: bool = Field(alias="includeEvents")
44
+
45
+
46
+ class CancelParams(WireModel):
47
+ id: int = Field(ge=0)
48
+
49
+
50
+ class EmptyParams(WireModel):
51
+ pass
52
+
53
+
54
+ class InitializeRequest(WireModel):
55
+ jsonrpc: Literal["2.0"]
56
+ id: int = Field(ge=0)
57
+ method: Literal["initialize"]
58
+ params: InitializeParams
59
+
60
+
61
+ class SearchRequest(WireModel):
62
+ jsonrpc: Literal["2.0"]
63
+ id: int = Field(ge=0)
64
+ method: Literal["search"]
65
+ params: SearchParams
66
+
67
+
68
+ class ReadRequest(WireModel):
69
+ jsonrpc: Literal["2.0"]
70
+ id: int = Field(ge=0)
71
+ method: Literal["read"]
72
+ params: ReadParams
73
+
74
+
75
+ class ShutdownRequest(WireModel):
76
+ jsonrpc: Literal["2.0"]
77
+ id: int = Field(ge=0)
78
+ method: Literal["shutdown"]
79
+ params: EmptyParams
80
+
81
+
82
+ class CancelRequest(WireModel):
83
+ jsonrpc: Literal["2.0"]
84
+ method: Literal["$/cancelRequest"]
85
+ params: CancelParams
86
+
87
+
88
+ RpcRequest: TypeAlias = InitializeRequest | SearchRequest | ReadRequest | ShutdownRequest | CancelRequest
89
+
90
+ _REQUEST_MODELS: dict[str, type[WireModel]] = {
91
+ "initialize": InitializeRequest,
92
+ "search": SearchRequest,
93
+ "read": ReadRequest,
94
+ "shutdown": ShutdownRequest,
95
+ "$/cancelRequest": CancelRequest,
96
+ }
97
+
98
+
99
+ class RpcErrorData(WireModel):
100
+ code: str
101
+ operation: str | None = None
102
+ stage: str | None = None
103
+ retryable: bool | None = None
104
+ provider: str | None = None
105
+ item_id: str | None = Field(default=None, alias="itemId")
106
+ message: str | None = None
107
+ details: dict[str, Any] | None = None
108
+
109
+
110
+ class RpcError(WireModel):
111
+ code: int
112
+ message: str
113
+ data: RpcErrorData | None = None
114
+
115
+
116
+ class RpcSuccessResponse(WireModel):
117
+ jsonrpc: Literal["2.0"] = "2.0"
118
+ id: int = Field(ge=0)
119
+ result: dict[str, Any]
120
+
121
+
122
+ class RpcErrorResponse(WireModel):
123
+ jsonrpc: Literal["2.0"] = "2.0"
124
+ id: int = Field(ge=0)
125
+ error: RpcError
126
+
127
+
128
+ RpcResponse: TypeAlias = RpcSuccessResponse | RpcErrorResponse
129
+
130
+
131
+ def decode_request(frame: bytes) -> RpcRequest:
132
+ """Decode one bounded request frame and reject unknown methods or fields."""
133
+ if len(frame) > MAX_FRAME_BYTES:
134
+ raise ValueError("request frame exceeds 8 MiB")
135
+ raw = json.loads(frame)
136
+ if not isinstance(raw, dict):
137
+ raise ValueError("request must be an object")
138
+ method = raw.get("method")
139
+ model = _REQUEST_MODELS.get(method) if isinstance(method, str) else None
140
+ if model is None:
141
+ raise ValueError("unsupported JSON-RPC method")
142
+ return model.model_validate(raw) # type: ignore[return-value]
143
+
144
+
145
+ def success_response(request_id: int, result: dict[str, Any]) -> RpcSuccessResponse:
146
+ """Create a successful correlated response."""
147
+ return RpcSuccessResponse(id=request_id, result=result)
148
+
149
+
150
+ def error_response(request_id: int, code: int, message: str, data: RpcErrorData) -> RpcErrorResponse:
151
+ """Create a failed correlated response with safe structured data."""
152
+ return RpcErrorResponse(id=request_id, error=RpcError(code=code, message=message, data=data))
153
+
154
+
155
+ def encode_response(response: RpcResponse) -> bytes:
156
+ """Serialize one response as a compact NDJSON frame."""
157
+ payload = response.model_dump_json(by_alias=True, exclude_none=True)
158
+ frame = f"{payload}\n".encode()
159
+ if len(frame) > MAX_FRAME_BYTES:
160
+ raise ValueError("response frame exceeds 8 MiB")
161
+ return frame
@@ -0,0 +1,61 @@
1
+ """Bounded evidence reader for the `sag_read` tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .evidence import EvidenceRefCodec
8
+ from .protocol import ReadParams
9
+
10
+
11
+ class ReadAdapter:
12
+ """Resolve an opaque reference and return one Unicode-safe content page."""
13
+
14
+ def __init__(self, pool: Any, codec: EvidenceRefCodec, *, max_read_chars: int, max_events: int) -> None:
15
+ self._pool = pool
16
+ self._codec = codec
17
+ self._max_read_chars = max_read_chars
18
+ self._max_events = max_events
19
+
20
+ async def read(self, params: ReadParams) -> dict[str, Any]:
21
+ locator = self._codec.decode(params.evidence_ref)
22
+ async with self._pool.read_engine(locator.namespace_id) as engine:
23
+ records = await engine.read_evidence(
24
+ locator.source_id,
25
+ (locator.chunk_id,),
26
+ max_events_per_chunk=self._max_events,
27
+ )
28
+ matches = tuple(record for record in records if record.chunk.id == locator.chunk_id)
29
+ if len(matches) != 1:
30
+ raise ValueError("evidence reference must resolve to exactly one chunk")
31
+ record = matches[0]
32
+ content = record.chunk.content
33
+ if params.offset > len(content):
34
+ raise ValueError("read offset is beyond the evidence content")
35
+ page_size = min(params.max_chars, self._max_read_chars)
36
+ page = content[params.offset : params.offset + page_size]
37
+ next_offset = params.offset + len(page)
38
+ result: dict[str, Any] = {
39
+ "title": record.chunk.heading or record.chunk.source_id,
40
+ "content": page,
41
+ "offset": params.offset,
42
+ "totalChars": len(content),
43
+ }
44
+ if next_offset < len(content):
45
+ result["nextOffset"] = next_offset
46
+ if params.include_events:
47
+ result["events"] = [
48
+ {
49
+ key: value
50
+ for key, value in {
51
+ "id": getattr(event, "id", None),
52
+ "title": getattr(event, "title", None),
53
+ "summary": getattr(event, "summary", None),
54
+ "category": getattr(event, "category", None),
55
+ "rank": getattr(event, "rank", None),
56
+ }.items()
57
+ if value is not None
58
+ }
59
+ for event in record.events[: self._max_events]
60
+ ]
61
+ return result
@@ -0,0 +1,76 @@
1
+ """Search Contract 2.0 adapter for the `sag_search` tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from zleap.sag.pipeline import SearchOptions, SearchRequest, SearchScope
8
+
9
+ from .evidence import EvidenceRefCodec
10
+ from .protocol import SearchParams
11
+
12
+ MODE_STRATEGY = {"fast": "vector", "precise": "full_expand"}
13
+
14
+
15
+ class SearchAdapter:
16
+ """Map the stable sidecar request into zleap-sag 0.10.0 search types."""
17
+
18
+ def __init__(
19
+ self,
20
+ pool: Any,
21
+ codec: EvidenceRefCodec,
22
+ namespaces: set[str] | frozenset[str],
23
+ *,
24
+ max_results: int,
25
+ max_excerpt_chars: int,
26
+ ) -> None:
27
+ self._pool = pool
28
+ self._codec = codec
29
+ self._namespaces = frozenset(namespaces)
30
+ self._max_results = max_results
31
+ self._max_excerpt_chars = max_excerpt_chars
32
+
33
+ async def search(self, params: SearchParams) -> dict[str, Any]:
34
+ if any(namespace not in self._namespaces for namespace in params.namespaces):
35
+ raise ValueError("search namespace is not configured")
36
+ limit = min(params.limit, self._max_results)
37
+ request = SearchRequest(
38
+ query=params.query,
39
+ scope=SearchScope(data_source_ids=params.namespaces),
40
+ options=SearchOptions(
41
+ strategy=MODE_STRATEGY[params.mode],
42
+ top_k=limit,
43
+ return_type="chunk",
44
+ ),
45
+ )
46
+ result = await (await self._pool.search_engine()).search(request)
47
+ chunk_hits = bool(result.chunks)
48
+ hits = result.chunks or tuple(hit for hit in result.events if hit.chunk_id)
49
+ evidences: list[dict[str, Any]] = []
50
+ seen: set[tuple[str, str, str]] = set()
51
+ for hit in hits:
52
+ if len(evidences) >= limit:
53
+ break
54
+ namespace_id = hit.data_source_id
55
+ source_id = hit.source_id
56
+ chunk_id = hit.chunk_id or (hit.id if chunk_hits else None)
57
+ if not namespace_id or not source_id or not chunk_id:
58
+ raise ValueError("SAG search hit does not contain readable identifiers")
59
+ key = (namespace_id, source_id, chunk_id)
60
+ if key in seen:
61
+ continue
62
+ seen.add(key)
63
+ chunk = hit.chunk[0] if hit.chunk else None
64
+ content = hit.content or (chunk.content if chunk is not None else "")
65
+ title = hit.title or (chunk.heading if chunk is not None else "") or source_id
66
+ evidence: dict[str, Any] = {
67
+ "evidenceRef": self._codec.encode_from_parts(namespace_id, source_id, chunk_id),
68
+ "namespaceId": namespace_id,
69
+ "sourceId": source_id,
70
+ "title": title,
71
+ "excerpt": content[: self._max_excerpt_chars],
72
+ }
73
+ if hit.score is not None:
74
+ evidence["score"] = hit.score
75
+ evidences.append(evidence)
76
+ return {"query": params.query, "evidences": evidences}
@@ -0,0 +1,136 @@
1
+ """Concurrent JSON-RPC request state machine for the stdio sidecar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from .errors import safe_error_data
9
+ from .protocol import (
10
+ RPC_PROTOCOL_VERSION,
11
+ CancelRequest,
12
+ InitializeRequest,
13
+ ReadRequest,
14
+ RpcErrorData,
15
+ RpcErrorResponse,
16
+ RpcRequest,
17
+ RpcResponse,
18
+ SearchRequest,
19
+ ShutdownRequest,
20
+ error_response,
21
+ success_response,
22
+ )
23
+
24
+ __all__ = ["RuntimeServer", "safe_error_data"]
25
+
26
+
27
+ class RuntimeServer:
28
+ """Own request admission, correlation, cancellation, and runtime shutdown."""
29
+
30
+ def __init__(self, pool: Any, search: Any, read: Any, namespaces: tuple[str, ...], *, engine_version: str) -> None:
31
+ self._pool = pool
32
+ self._search = search
33
+ self._read = read
34
+ self._namespaces = namespaces
35
+ self._engine_version = engine_version
36
+ self._state = "new"
37
+ self._live: dict[int, asyncio.Task[dict[str, Any]]] = {}
38
+ self._pool_closed = False
39
+
40
+ def _state_error(self, request_id: int, code: str, message: str) -> RpcErrorResponse:
41
+ return error_response(request_id, -32002, message, RpcErrorData(code=code, retryable=False, message=message))
42
+
43
+ async def accept(self, request: RpcRequest) -> RpcResponse | None:
44
+ if isinstance(request, CancelRequest):
45
+ task = self._live.get(request.params.id)
46
+ if task is not None and not task.done():
47
+ task.cancel()
48
+ try:
49
+ await task
50
+ except asyncio.CancelledError:
51
+ pass
52
+ except Exception:
53
+ pass
54
+ return None
55
+
56
+ request_id = request.id
57
+ if request_id in self._live:
58
+ return self._state_error(request_id, "DSH_SAG_DUPLICATE_ID", "request id is already live")
59
+ if self._state in {"shutting", "closed"}:
60
+ return self._state_error(request_id, "DSH_SAG_SHUTTING_DOWN", "SAG runtime is shutting down")
61
+ if isinstance(request, InitializeRequest):
62
+ if self._state != "new":
63
+ return self._state_error(request_id, "DSH_SAG_ALREADY_INITIALIZED", "SAG runtime is already initialized")
64
+ self._state = "initializing"
65
+ elif self._state != "ready":
66
+ return self._state_error(request_id, "DSH_SAG_NOT_INITIALIZED", "initialize must complete before other requests")
67
+ elif isinstance(request, ShutdownRequest):
68
+ self._state = "shutting"
69
+
70
+ task = asyncio.create_task(self._execute(request))
71
+ self._live[request_id] = task
72
+ try:
73
+ result = await task
74
+ return success_response(request_id, result)
75
+ except asyncio.CancelledError:
76
+ if isinstance(request, InitializeRequest):
77
+ self._state = "new"
78
+ return error_response(
79
+ request_id,
80
+ -32800,
81
+ "request cancelled",
82
+ RpcErrorData(code="DSH_SAG_CANCELLED", retryable=True, message="request cancelled"),
83
+ )
84
+ except BaseException as error:
85
+ if isinstance(request, InitializeRequest):
86
+ self._state = "new"
87
+ data = safe_error_data(error)
88
+ return error_response(request_id, -32602 if isinstance(error, ValueError) else -32603, data.message or "SAG runtime request failed", data)
89
+ finally:
90
+ self._live.pop(request_id, None)
91
+
92
+ async def _execute(self, request: InitializeRequest | SearchRequest | ReadRequest | ShutdownRequest) -> dict[str, Any]:
93
+ if isinstance(request, InitializeRequest):
94
+ await self._pool.start()
95
+ capabilities = await self._pool.capabilities()
96
+ if not capabilities.evidence_read:
97
+ raise RuntimeError("zleap-sag runtime does not support evidence reads")
98
+ health = await self._pool.health()
99
+ health_state = getattr(health.state, "value", health.state)
100
+ self._state = "ready"
101
+ return {
102
+ "protocolVersion": RPC_PROTOCOL_VERSION,
103
+ "engineVersion": self._engine_version,
104
+ "health": str(health_state),
105
+ "evidenceRead": True,
106
+ "namespaces": list(self._namespaces),
107
+ }
108
+ if isinstance(request, SearchRequest):
109
+ return await self._search.search(request.params)
110
+ if isinstance(request, ReadRequest):
111
+ return await self._read.read(request.params)
112
+ current = asyncio.current_task()
113
+ others = [task for task in self._live.values() if task is not current and not task.done()]
114
+ for task in others:
115
+ task.cancel()
116
+ if others:
117
+ await asyncio.gather(*others, return_exceptions=True)
118
+ await self._close_pool()
119
+ return {}
120
+
121
+ async def _close_pool(self) -> None:
122
+ if not self._pool_closed:
123
+ self._pool_closed = True
124
+ await self._pool.aclose()
125
+
126
+ async def aclose(self) -> None:
127
+ if self._state == "closed":
128
+ return
129
+ self._state = "closed"
130
+ current = asyncio.current_task()
131
+ tasks = [task for task in self._live.values() if task is not current and not task.done()]
132
+ for task in tasks:
133
+ task.cancel()
134
+ if tasks:
135
+ await asyncio.gather(*tasks, return_exceptions=True)
136
+ await self._close_pool()