ten-grd 0.2.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.
ten_grd/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Tennant Guard: tenant isolation primitives for agentic developers."""
2
+ from .adapters import InMemoryAdapter, IsolationAdapter
3
+ from .client import TenantGuard
4
+ from .context import TenantContext, current_context, tenant_scope
5
+ from .engine import VerificationResult, VerificationStatus, Z3InvariantEngine
6
+ from .exceptions import (GuardError, InvalidContextError, InvalidOperationError,
7
+ IsolationViolationError, VerificationError, AdapterError)
8
+ from .predicates import And, Eq, Not, Or
9
+
10
+ __version__ = "0.2.0"
11
+ __all__ = [
12
+ "TenantGuard", "TenantContext", "tenant_scope", "current_context",
13
+ "InMemoryAdapter", "IsolationAdapter", "Z3InvariantEngine",
14
+ "VerificationResult", "VerificationStatus", "Eq", "And", "Or", "Not",
15
+ "GuardError", "InvalidContextError", "InvalidOperationError",
16
+ "IsolationViolationError", "VerificationError", "AdapterError",
17
+ ]
ten_grd/adapters.py ADDED
@@ -0,0 +1,48 @@
1
+ """Trusted adapter contract and atomic, in-memory reference implementation."""
2
+ from threading import RLock
3
+ from typing import Protocol
4
+
5
+ from .exceptions import InvalidOperationError, IsolationViolationError
6
+ from .operations import Operation, OperationKind, Record
7
+ from .predicates import matches
8
+
9
+
10
+ class IsolationAdapter(Protocol):
11
+ """Implementations must preserve the exact checked operation semantics.
12
+
13
+ An adapter is trusted enforcement code, not an untrusted plugin. Backend
14
+ implementations must bind ownership atomically and document model fidelity.
15
+ """
16
+ def execute(self, operation: Operation) -> tuple[Record, ...]: ...
17
+
18
+
19
+ class InMemoryAdapter:
20
+ """Reference metadata semantics; intentionally not a persistent backend."""
21
+
22
+ def __init__(self):
23
+ self._records: dict[tuple[str, str], Record] = {}
24
+ self._lock = RLock()
25
+
26
+ def execute(self, operation: Operation) -> tuple[Record, ...]:
27
+ if type(operation) is not Operation:
28
+ raise InvalidOperationError("Expected an Operation")
29
+ operation.validate()
30
+ with self._lock:
31
+ if operation.kind is OperationKind.SEARCH:
32
+ return tuple(record for record in self._records.values()
33
+ if record.tenant_id == operation.tenant_id
34
+ and matches(operation.selector, record.fields()))
35
+ key = (operation.tenant_id, operation.record_id)
36
+ if operation.kind is OperationKind.PUT:
37
+ record = operation.record
38
+ if (record.tenant_id != operation.tenant_id
39
+ or not matches(operation.selector, record.fields())):
40
+ raise IsolationViolationError("Write does not satisfy its checked scope")
41
+ self._records[key] = record
42
+ return (record,)
43
+ record = self._records.get(key)
44
+ if record is None or not matches(operation.selector, record.fields()):
45
+ return ()
46
+ if operation.kind is OperationKind.DELETE:
47
+ del self._records[key]
48
+ return (record,)
ten_grd/client.py ADDED
@@ -0,0 +1,61 @@
1
+ """Guarded operations for agent tools, memory, and application data."""
2
+ from .context import current_context
3
+ from .engine import Z3InvariantEngine
4
+ from .exceptions import InvalidOperationError, IsolationViolationError, VerificationError
5
+ from .operations import Operation, OperationKind, Record
6
+ from .predicates import And, Eq, Predicate, matches, validate_predicate
7
+
8
+
9
+ class TenantGuard:
10
+ def __init__(self, adapter, *, engine=None):
11
+ self._adapter = adapter
12
+ self._engine = engine if engine is not None else Z3InvariantEngine()
13
+
14
+ def search(self, predicate: Predicate | None = None) -> tuple[Record, ...]:
15
+ return self._run(OperationKind.SEARCH, predicate=predicate)
16
+
17
+ def get(self, record_id: str) -> Record | None:
18
+ records = self._run(OperationKind.GET, record_id=record_id)
19
+ return records[0] if records else None
20
+
21
+ def put(self, record_id: str, data, *, metadata=None) -> Record:
22
+ context = current_context()
23
+ record = Record.create(context.tenant_id, record_id, data, metadata)
24
+ records = self._run(OperationKind.PUT, record_id=record_id, record=record)
25
+ if len(records) != 1 or records[0] != record:
26
+ raise VerificationError("Adapter did not return the exact written record")
27
+ return records[0]
28
+
29
+ def delete(self, record_id: str) -> bool:
30
+ return bool(self._run(OperationKind.DELETE, record_id=record_id))
31
+
32
+ def _run(self, kind, *, predicate=None, record_id=None, record=None):
33
+ context = current_context()
34
+ terms = [Eq("tenant_id", context.tenant_id)]
35
+ if predicate is not None:
36
+ validate_predicate(predicate)
37
+ terms.append(predicate)
38
+ if record_id is not None:
39
+ terms.append(Eq("record_id", record_id))
40
+ operation = Operation(context.tenant_id, kind, And(tuple(terms)),
41
+ record_id, record)
42
+ result = self._engine.verify_operation(context, operation)
43
+ result.require_proven()
44
+ if (result.operation_digest != operation.digest
45
+ or result.policy_version != context.policy_version):
46
+ raise VerificationError("Verification evidence does not match the operation")
47
+ if current_context() != context:
48
+ raise VerificationError("Tenant context changed during verification")
49
+ records = self._adapter.execute(operation)
50
+ # Materialize and validate the entire batch before returning any content.
51
+ if type(records) is not tuple:
52
+ raise InvalidOperationError("Adapter must return a materialized record tuple")
53
+ if kind is not OperationKind.SEARCH and len(records) > 1:
54
+ raise VerificationError("Adapter returned multiple records for one target")
55
+ for item in records:
56
+ if type(item) is not Record:
57
+ raise IsolationViolationError("Adapter returned an unsupported or unlabeled record")
58
+ self._engine.verify_ownership(context, item.tenant_id)
59
+ if not matches(operation.selector, item.fields()):
60
+ raise IsolationViolationError("Adapter result falls outside the checked selector")
61
+ return records
ten_grd/context.py ADDED
@@ -0,0 +1,49 @@
1
+ """Request-local scope supplied by the host authentication boundary."""
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass
5
+
6
+ from .exceptions import InvalidContextError
7
+
8
+ _MAX_IDENTIFIER = 256
9
+
10
+
11
+ def validate_identifier(value: str, name: str) -> None:
12
+ if (not isinstance(value, str) or not value or len(value) > _MAX_IDENTIFIER
13
+ or value != value.strip() or any(ord(c) < 32 for c in value)):
14
+ raise InvalidContextError(f"{name} must be a nonempty canonical identifier")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TenantContext:
19
+ """Create only after the host validates identity and tenant membership."""
20
+ tenant_id: str
21
+ subject_id: str
22
+ request_id: str
23
+ policy_version: str = "1"
24
+
25
+ def __post_init__(self):
26
+ for name in ("tenant_id", "subject_id", "request_id", "policy_version"):
27
+ validate_identifier(getattr(self, name), name)
28
+
29
+
30
+ _current: ContextVar[TenantContext | None] = ContextVar("ten_grd_context", default=None)
31
+
32
+
33
+ def current_context() -> TenantContext:
34
+ context = _current.get()
35
+ if context is None:
36
+ raise InvalidContextError("No authenticated tenant context is active")
37
+ return context
38
+
39
+
40
+ @contextmanager
41
+ def tenant_scope(context: TenantContext):
42
+ """Bind validated context; always restore the prior scope on exit."""
43
+ if type(context) is not TenantContext:
44
+ raise InvalidContextError("Expected a validated TenantContext")
45
+ token = _current.set(context)
46
+ try:
47
+ yield context
48
+ finally:
49
+ _current.reset(token)
ten_grd/engine.py ADDED
@@ -0,0 +1,154 @@
1
+ """Prove isolation of the supported selector model; fail closed otherwise."""
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ import hashlib
5
+ import json
6
+ import z3
7
+
8
+ from .context import TenantContext, validate_identifier
9
+ from .exceptions import (GuardError, IsolationViolationError,
10
+ InvalidOperationError, VerificationError)
11
+ from .operations import Operation, OperationKind
12
+ from .predicates import And, Eq, Not, Or, predicate_data, validate_predicate
13
+
14
+ MODEL_VERSION = "string-metadata-v1"
15
+
16
+
17
+ class VerificationStatus(str, Enum):
18
+ PROVEN = "proven"
19
+ VIOLATION = "violation"
20
+ UNKNOWN = "unknown"
21
+ UNSUPPORTED = "unsupported"
22
+ ERROR = "error"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class VerificationResult:
27
+ status: VerificationStatus
28
+ operation_digest: str
29
+ policy_version: str
30
+ reason: str
31
+ model_version: str = MODEL_VERSION
32
+
33
+ def require_proven(self):
34
+ if self.status is VerificationStatus.VIOLATION:
35
+ raise IsolationViolationError(self.reason)
36
+ if self.status is not VerificationStatus.PROVEN:
37
+ raise VerificationError(self.reason)
38
+
39
+
40
+ class Z3InvariantEngine:
41
+ """No raw queries or caller-provided filter booleans are accepted."""
42
+
43
+ def __init__(self, timeout_ms: int = 1000):
44
+ if type(timeout_ms) is not int or not 1 <= timeout_ms <= 60_000:
45
+ raise ValueError("timeout_ms must be between 1 and 60000")
46
+ self.timeout_ms = timeout_ms
47
+
48
+ def verify_selector(self, context: TenantContext, selector) -> VerificationResult:
49
+ return self._verify(context, selector, "")
50
+
51
+ def verify_scope(self, context: TenantContext, selector, *, required) -> VerificationResult:
52
+ """Prove tenant, subject, policy and additional cache scope dimensions."""
53
+ try:
54
+ if type(context) is not TenantContext:
55
+ raise InvalidOperationError("Validated context required")
56
+ expected = {"tenant_id": context.tenant_id, "subject_id": context.subject_id,
57
+ "policy_version": context.policy_version}
58
+ extra = dict(required)
59
+ if any(key in expected and expected[key] != value for key, value in extra.items()):
60
+ return VerificationResult(VerificationStatus.VIOLATION, "", "",
61
+ "Scope conflicts with authenticated context")
62
+ expected.update(extra)
63
+ validate_predicate(And(tuple(Eq(k, v) for k, v in expected.items())))
64
+ return self._verify(context, selector, "", required=expected)
65
+ except Exception:
66
+ return VerificationResult(VerificationStatus.UNSUPPORTED, "", "",
67
+ "Scope requirements are unsupported")
68
+
69
+ def verify_operation(self, context: TenantContext,
70
+ operation: Operation) -> VerificationResult:
71
+ try:
72
+ if type(operation) is not Operation:
73
+ raise InvalidOperationError("Expected an immutable Operation")
74
+ operation.validate()
75
+ if type(context) is not TenantContext or operation.tenant_id != context.tenant_id:
76
+ return VerificationResult(VerificationStatus.VIOLATION, "",
77
+ "", "Operation tenant differs from active context")
78
+ if (operation.kind is OperationKind.PUT
79
+ and operation.record.tenant_id != context.tenant_id):
80
+ raise IsolationViolationError("Write ownership differs from context")
81
+ return self._verify(context, operation.selector, operation.digest)
82
+ except GuardError:
83
+ return VerificationResult(VerificationStatus.UNSUPPORTED, "", "",
84
+ "Operation is outside the supported contract")
85
+
86
+ def _verify(self, context, selector, digest, required=None):
87
+ try:
88
+ if type(context) is not TenantContext:
89
+ raise InvalidOperationError("A validated context is required")
90
+ validate_predicate(selector)
91
+ if not digest:
92
+ raw = json.dumps([context.tenant_id, context.policy_version,
93
+ predicate_data(selector), required], ensure_ascii=True)
94
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
95
+ # Per-call contexts prevent Z3 AST/solver sharing across requests.
96
+ ctx = z3.Context()
97
+ solver = z3.Solver(ctx=ctx)
98
+ solver.set(timeout=self.timeout_ms)
99
+ fields = {}
100
+
101
+ def field(name):
102
+ if name not in fields:
103
+ index = len(fields)
104
+ fields[name] = (z3.String(f"v{index}", ctx=ctx),
105
+ z3.Bool(f"p{index}", ctx=ctx))
106
+ return fields[name]
107
+
108
+ def compile_node(node):
109
+ if type(node) is Eq:
110
+ value, present = field(node.field)
111
+ return z3.And(present, value == z3.StringVal(node.value, ctx=ctx))
112
+ if type(node) is And:
113
+ terms = [compile_node(p) for p in node.terms]
114
+ return z3.And(*terms) if terms else z3.BoolVal(True, ctx=ctx)
115
+ if type(node) is Or:
116
+ terms = [compile_node(p) for p in node.terms]
117
+ return z3.Or(*terms) if terms else z3.BoolVal(False, ctx=ctx)
118
+ return z3.Not(compile_node(node.term))
119
+
120
+ selected = compile_node(selector)
121
+ expected = {"tenant_id": context.tenant_id}
122
+ expected.update(required or {})
123
+ violations = []
124
+ for name, expected_value in expected.items():
125
+ value, present = field(name)
126
+ violations.append(z3.Or(z3.Not(present),
127
+ value != z3.StringVal(expected_value, ctx=ctx)))
128
+ solver.add(selected, z3.Or(*violations))
129
+ outcome = solver.check()
130
+ if outcome == z3.unsat:
131
+ status, reason = VerificationStatus.PROVEN, "Selector preserves tenant ownership"
132
+ elif outcome == z3.sat:
133
+ status, reason = VerificationStatus.VIOLATION, "Selector can include foreign or unlabeled data"
134
+ else:
135
+ status = VerificationStatus.UNKNOWN
136
+ reason = "Solver inconclusive: " + solver.reason_unknown()
137
+ return VerificationResult(status, digest, context.policy_version, reason)
138
+ except GuardError:
139
+ return VerificationResult(VerificationStatus.UNSUPPORTED, digest, "",
140
+ "Selector or context is unsupported")
141
+ except Exception:
142
+ # Do not expose solver/input details or interpret errors as success.
143
+ return VerificationResult(VerificationStatus.ERROR, digest, "",
144
+ "Verification failed")
145
+
146
+ def verify_ownership(self, context: TenantContext, owner: str) -> None:
147
+ if type(context) is not TenantContext:
148
+ raise VerificationError("A validated context is required")
149
+ try:
150
+ validate_identifier(owner, "owner")
151
+ except GuardError as exc:
152
+ raise IsolationViolationError("Resource ownership is missing or invalid") from exc
153
+ if owner != context.tenant_id:
154
+ raise IsolationViolationError("Resource belongs to another tenant")
ten_grd/exceptions.py ADDED
@@ -0,0 +1,25 @@
1
+ """Failures never authorize an operation."""
2
+
3
+
4
+ class GuardError(Exception):
5
+ """Base error raised before protected output is exposed."""
6
+
7
+
8
+ class InvalidContextError(GuardError):
9
+ """A valid host-authenticated tenant context is required."""
10
+
11
+
12
+ class InvalidOperationError(GuardError):
13
+ """The operation is outside the supported contract."""
14
+
15
+
16
+ class VerificationError(GuardError):
17
+ """Verification did not establish isolation."""
18
+
19
+
20
+ class IsolationViolationError(VerificationError):
21
+ """A modeled violation or foreign-owned result was detected."""
22
+
23
+
24
+ class AdapterError(GuardError):
25
+ """A backend operation failed; execution never falls back to broader scope."""
@@ -0,0 +1 @@
1
+ """Optional backends. Import modules individually to load only needed drivers."""
@@ -0,0 +1,53 @@
1
+ """Shared record codec and enforcement for backend adapters."""
2
+ from ..context import current_context
3
+ from ..exceptions import IsolationViolationError, VerificationError
4
+ from ..operations import Operation, OperationKind, Record
5
+ from ..predicates import And, Eq, matches, validate_predicate
6
+
7
+
8
+ def payload(record):
9
+ return {"tenant_id": record.tenant_id, "record_id": record.record_id,
10
+ "data_json": record.data_json, "metadata": dict(record.metadata)}
11
+
12
+
13
+ def decode_record(value):
14
+ try:
15
+ if type(value) is not dict or set(value) != {"tenant_id", "record_id", "data_json", "metadata"}:
16
+ raise ValueError()
17
+ if type(value["metadata"]) is not dict:
18
+ raise ValueError()
19
+ return Record(value["tenant_id"], value["record_id"], value["data_json"],
20
+ tuple(value["metadata"].items()))
21
+ except Exception:
22
+ raise IsolationViolationError("Backend returned an invalid ownership envelope") from None
23
+
24
+
25
+ def authorize(context, operation, engine):
26
+ result = engine.verify_operation(context, operation)
27
+ result.require_proven()
28
+ if (result.operation_digest != operation.digest
29
+ or result.policy_version != context.policy_version
30
+ or current_context() != context):
31
+ raise VerificationError("Operation evidence or active context changed")
32
+
33
+
34
+ def scoped_operation(context, predicate=None, *, kind=OperationKind.SEARCH,
35
+ record_id=None, record=None):
36
+ terms = [Eq("tenant_id", context.tenant_id)]
37
+ if predicate is not None:
38
+ validate_predicate(predicate)
39
+ terms.append(predicate)
40
+ if record_id is not None:
41
+ terms.append(Eq("record_id", record_id))
42
+ return Operation(context.tenant_id, kind, And(tuple(terms)), record_id, record)
43
+
44
+
45
+ def checked_records(context, operation, records, engine):
46
+ result = tuple(records)
47
+ for record in result:
48
+ engine.verify_ownership(context, record.tenant_id)
49
+ if not matches(operation.selector, record.fields()):
50
+ raise IsolationViolationError("Backend result is outside the verified selector")
51
+ if current_context() != context:
52
+ raise VerificationError("Active context changed during backend execution")
53
+ return result
@@ -0,0 +1,126 @@
1
+ """Exact cache scopes bind tenant, subject, policy and configuration."""
2
+ from dataclasses import dataclass
3
+ import hashlib
4
+ import json
5
+
6
+ from ..context import current_context, validate_identifier
7
+ from ..engine import Z3InvariantEngine
8
+ from ..exceptions import AdapterError, InvalidOperationError, IsolationViolationError, VerificationError
9
+ from ..operations import _json
10
+ from ..predicates import And, Eq
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CacheScope:
15
+ tenant_id: str
16
+ subject_id: str
17
+ policy_version: str
18
+ namespace: str
19
+ model_version: str
20
+
21
+ def fields(self):
22
+ return dict(tenant_id=self.tenant_id, subject_id=self.subject_id,
23
+ policy_version=self.policy_version, namespace=self.namespace,
24
+ model_version=self.model_version)
25
+
26
+ def selector(self):
27
+ return And(tuple(Eq(k, v) for k, v in self.fields().items()))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CacheHit:
32
+ value_json: str
33
+
34
+ @property
35
+ def data(self):
36
+ return json.loads(self.value_json)
37
+
38
+
39
+ def active_scope(namespace, model_version, engine):
40
+ context = current_context()
41
+ scope = CacheScope(context.tenant_id, context.subject_id, context.policy_version,
42
+ namespace, model_version)
43
+ engine.verify_scope(context, scope.selector(), required=scope.fields()).require_proven()
44
+ if current_context() != context:
45
+ raise VerificationError("Active context changed during cache verification")
46
+ return context, scope
47
+
48
+
49
+ def request_json(request):
50
+ encoded = _json(request)
51
+ if len(encoded) > 65536:
52
+ raise InvalidOperationError("Cache request exceeds 64 KiB")
53
+ return encoded
54
+
55
+
56
+ def cache_key(scope, encoded_request):
57
+ # Hashing is NOT modeled as injective. Reads validate the full binding.
58
+ canonical = _json(["ten-grd-cache-v1", scope.fields(), encoded_request])
59
+ return "ten-grd:cache:v1:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
60
+
61
+
62
+ def validate_ttl(ttl_seconds):
63
+ if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= 31_536_000:
64
+ raise InvalidOperationError("TTL must be an integer between 1 and 31536000 seconds")
65
+
66
+
67
+ class RedisCache:
68
+ """Synchronous redis-py client or compatible backend; no broad-scope fallback."""
69
+ def __init__(self, client, *, namespace, model_version, engine=None):
70
+ validate_identifier(namespace, "namespace")
71
+ validate_identifier(model_version, "model_version")
72
+ self.client, self.namespace, self.model_version = client, namespace, model_version
73
+ self.engine = engine if engine is not None else Z3InvariantEngine()
74
+
75
+ def _binding(self, request):
76
+ context, scope = active_scope(self.namespace, self.model_version, self.engine)
77
+ encoded = request_json(request)
78
+ return context, scope, encoded, cache_key(scope, encoded)
79
+
80
+ def set(self, request, data, *, ttl_seconds=300):
81
+ validate_ttl(ttl_seconds)
82
+ context, scope, encoded, key = self._binding(request)
83
+ value_json = _json(data)
84
+ if len(value_json) > 1_048_576:
85
+ raise InvalidOperationError("Cached value exceeds 1 MiB")
86
+ envelope = _json({"scope": scope.fields(), "request_json": encoded,
87
+ "value_json": value_json})
88
+ if current_context() != context:
89
+ raise VerificationError("Active context changed before cache write")
90
+ try:
91
+ written = self.client.set(key, envelope, ex=ttl_seconds)
92
+ except Exception:
93
+ raise AdapterError("Redis cache write failed") from None
94
+ if not written:
95
+ raise AdapterError("Redis did not acknowledge the cache write")
96
+
97
+ def get(self, request):
98
+ context, scope, encoded, key = self._binding(request)
99
+ try:
100
+ raw = self.client.get(key)
101
+ except Exception:
102
+ raise AdapterError("Redis cache lookup failed") from None
103
+ if raw is None:
104
+ return None
105
+ try:
106
+ envelope = json.loads(raw)
107
+ if (type(envelope) is not dict or envelope["scope"] != scope.fields()
108
+ or envelope["request_json"] != encoded
109
+ or not isinstance(envelope["value_json"], str)):
110
+ raise ValueError()
111
+ value = json.loads(envelope["value_json"])
112
+ canonical = _json(value)
113
+ if len(canonical) > 1_048_576:
114
+ raise ValueError()
115
+ except Exception:
116
+ raise IsolationViolationError("Cache hit has invalid ownership or request binding") from None
117
+ if current_context() != context:
118
+ raise VerificationError("Active context changed during cache lookup")
119
+ return CacheHit(canonical)
120
+
121
+ def delete(self, request):
122
+ _, _, _, key = self._binding(request)
123
+ try:
124
+ return bool(self.client.delete(key))
125
+ except Exception:
126
+ raise AdapterError("Redis cache invalidation failed") from None
@@ -0,0 +1,89 @@
1
+ """LangChain tools with invocation-local scope and no tenant arguments."""
2
+ import asyncio
3
+ from functools import wraps
4
+ from typing import Any
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+ from langchain_core.tools import StructuredTool
7
+
8
+ from ..context import current_context
9
+ from ..exceptions import InvalidOperationError, VerificationError
10
+ from ..predicates import Eq
11
+
12
+
13
+ class _Args(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+
17
+ class _RecordArgs(_Args):
18
+ record_id: str
19
+
20
+
21
+ class _SearchArgs(_Args):
22
+ field: str | None = None
23
+ value: str | None = None
24
+
25
+
26
+ class _PutArgs(_RecordArgs):
27
+ data: Any
28
+ metadata: dict[str, str] | None = None
29
+
30
+
31
+ class _RetrievalArgs(_Args):
32
+ query: str
33
+ limit: int = Field(default=10, ge=1, le=1000)
34
+
35
+
36
+ def _tool(function, name, description, schema):
37
+ @wraps(function)
38
+ async def asynchronous(*args, **kwargs):
39
+ # to_thread propagates the invocation's contextvars, not creation scope.
40
+ return await asyncio.to_thread(function, *args, **kwargs)
41
+ return StructuredTool.from_function(func=function, coroutine=asynchronous,
42
+ name=name, description=description, args_schema=schema)
43
+
44
+
45
+ def _prefix(prefix):
46
+ if not isinstance(prefix, str) or not prefix.isidentifier() or len(prefix) > 64:
47
+ raise InvalidOperationError("Tool prefix must be an identifier of at most 64 characters")
48
+
49
+
50
+ def memory_tools(guard, *, prefix="grd", include_writes=False):
51
+ """Host authenticates each invocation; tools never capture a tenant at creation."""
52
+ _prefix(prefix)
53
+ def read(record_id):
54
+ record = guard.get(record_id)
55
+ return None if record is None else record.data
56
+
57
+ def search(field=None, value=None):
58
+ if (field is None) != (value is None):
59
+ raise InvalidOperationError("Provide both field and value, or neither")
60
+ predicate = None if field is None else Eq(field, value)
61
+ return [{"record_id": record.record_id, "data": record.data}
62
+ for record in guard.search(predicate)]
63
+
64
+ tools = [
65
+ _tool(read, prefix + "_read", "Read a record in the authenticated tenant.", _RecordArgs),
66
+ _tool(search, prefix + "_search", "Search records in the authenticated tenant.", _SearchArgs),
67
+ ]
68
+ if include_writes:
69
+ def put(record_id, data, metadata=None):
70
+ return guard.put(record_id, data, metadata=metadata).data
71
+ def delete(record_id):
72
+ return guard.delete(record_id)
73
+ tools.extend([
74
+ _tool(put, prefix + "_put", "Write a record in the authenticated tenant.", _PutArgs),
75
+ _tool(delete, prefix + "_delete", "Delete a record in the authenticated tenant.", _RecordArgs),
76
+ ])
77
+ return tools
78
+
79
+
80
+ def retrieval_tool(retriever, embed_query, *, name="grd_retrieve"):
81
+ _prefix(name)
82
+ def retrieve(query, limit=10):
83
+ context = current_context()
84
+ vector = embed_query(query)
85
+ if current_context() != context:
86
+ raise VerificationError("Embedding callback changed the active context")
87
+ return [{"record_id": hit.record.record_id, "data": hit.record.data, "score": hit.score}
88
+ for hit in retriever.search(vector, limit=limit)]
89
+ return _tool(retrieve, name, "Retrieve documents within the authenticated tenant.", _RetrievalArgs)