@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/README.zh.md +58 -0
- package/THIRD_PARTY_NOTICES +671 -0
- package/cordis.patch.yml +5 -0
- package/docs/embedded.md +61 -0
- package/lib/brand.d.ts +13 -0
- package/lib/brand.js +15 -0
- package/lib/cli/runtime.d.ts +40 -0
- package/lib/cli/runtime.js +121 -0
- package/lib/cli.d.ts +21 -0
- package/lib/cli.js +265 -0
- package/lib/config.d.ts +60 -0
- package/lib/config.js +120 -0
- package/lib/connection/descriptor.d.ts +4 -0
- package/lib/connection/descriptor.js +66 -0
- package/lib/connection/discovery.d.ts +40 -0
- package/lib/connection/discovery.js +139 -0
- package/lib/connection/guidance.d.ts +7 -0
- package/lib/connection/guidance.js +7 -0
- package/lib/connection/manager.d.ts +67 -0
- package/lib/connection/manager.js +273 -0
- package/lib/connection/store.d.ts +42 -0
- package/lib/connection/store.js +164 -0
- package/lib/connection/types.d.ts +35 -0
- package/lib/connection/types.js +2 -0
- package/lib/dsh-sag-cli.js +32202 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +68 -0
- package/lib/local/api-client.d.ts +157 -0
- package/lib/local/api-client.js +338 -0
- package/lib/local/gateway.d.ts +43 -0
- package/lib/local/gateway.js +34 -0
- package/lib/local/mcp-probe.d.ts +27 -0
- package/lib/local/mcp-probe.js +92 -0
- package/lib/presentation.d.ts +8 -0
- package/lib/presentation.js +7 -0
- package/lib/runtime/client.d.ts +22 -0
- package/lib/runtime/client.js +117 -0
- package/lib/runtime/protocol.d.ts +113 -0
- package/lib/runtime/protocol.js +118 -0
- package/lib/runtime/supervisor.d.ts +30 -0
- package/lib/runtime/supervisor.js +107 -0
- package/lib/tools/documents.d.ts +8 -0
- package/lib/tools/documents.js +134 -0
- package/lib/tools/ingest.d.ts +5 -0
- package/lib/tools/ingest.js +35 -0
- package/lib/tools/local.d.ts +40 -0
- package/lib/tools/local.js +111 -0
- package/lib/tools/output.d.ts +96 -0
- package/lib/tools/output.js +67 -0
- package/lib/tools/read.d.ts +6 -0
- package/lib/tools/read.js +84 -0
- package/lib/tools/search.d.ts +6 -0
- package/lib/tools/search.js +107 -0
- package/lib/tools/sources.d.ts +5 -0
- package/lib/tools/sources.js +61 -0
- package/lib/tools/status.d.ts +5 -0
- package/lib/tools/status.js +28 -0
- package/lib/tools/upload.d.ts +6 -0
- package/lib/tools/upload.js +68 -0
- package/package.json +96 -0
- package/runtime/pyproject.toml +29 -0
- package/runtime/src/dsh_sag_runtime/__init__.py +3 -0
- package/runtime/src/dsh_sag_runtime/__main__.py +80 -0
- package/runtime/src/dsh_sag_runtime/engines.py +139 -0
- package/runtime/src/dsh_sag_runtime/errors.py +47 -0
- package/runtime/src/dsh_sag_runtime/evidence.py +53 -0
- package/runtime/src/dsh_sag_runtime/protocol.py +161 -0
- package/runtime/src/dsh_sag_runtime/read.py +61 -0
- package/runtime/src/dsh_sag_runtime/search.py +76 -0
- package/runtime/src/dsh_sag_runtime/server.py +136 -0
- package/runtime/uv.lock +2310 -0
- 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()
|