agent-framework-dxmt 1.6.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.
- agent_framework_dxmt/__init__.py +14 -0
- agent_framework_dxmt/_context_provider.py +203 -0
- agent_framework_dxmt/_filters.py +117 -0
- agent_framework_dxmt/_settings.py +81 -0
- agent_framework_dxmt/_vector_store.py +498 -0
- agent_framework_dxmt/py.typed +0 -0
- agent_framework_dxmt-1.6.0.dist-info/METADATA +123 -0
- agent_framework_dxmt-1.6.0.dist-info/RECORD +10 -0
- agent_framework_dxmt-1.6.0.dist-info/WHEEL +4 -0
- agent_framework_dxmt-1.6.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""DXMT for Microsoft Agent Framework: a vector store, and memory as a context provider."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
from ._context_provider import DXMTContextProvider
|
|
6
|
+
from ._settings import DXMTSettings
|
|
7
|
+
from ._vector_store import DXMTCollection, DXMTStore
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = importlib.metadata.version("agent-framework-dxmt")
|
|
11
|
+
except importlib.metadata.PackageNotFoundError:
|
|
12
|
+
__version__ = "0.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["DXMTCollection", "DXMTContextProvider", "DXMTSettings", "DXMTStore", "__version__"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""DXMT memory as an Agent Framework context provider.
|
|
2
|
+
|
|
3
|
+
Before a run, the user's input is sent to DXMT's ``recall`` (hybrid: by meaning and by
|
|
4
|
+
keyword) and the memories come back into the context under a short heading; after a run,
|
|
5
|
+
the user and assistant messages are kept with ``remember``. Scope is tags: a memory is
|
|
6
|
+
tagged with the ``user_id``, ``agent_id`` and ``application_id`` it was written under, and
|
|
7
|
+
recall asks for the ``search_*`` ids -- kept separate from the storage scope, as the mem0
|
|
8
|
+
provider keeps them, so a memory written under a shared agent id is not read back for an
|
|
9
|
+
unrelated user unless ``search_agent_id`` says so.
|
|
10
|
+
|
|
11
|
+
DXMT's memory semantics come along: a memory said twice is kept once, a new fact can
|
|
12
|
+
``supersede`` an old one (kept as history, left out of recall), and ``forget`` removes on
|
|
13
|
+
request. The same store is reachable from every other agent on the machine over MCP and REST.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Self
|
|
20
|
+
|
|
21
|
+
from agent_framework import AgentSession, ContextProvider, Message, SecretString, SessionContext
|
|
22
|
+
from dxmt import DXMTClient
|
|
23
|
+
from dxmt.exceptions import DXMTError
|
|
24
|
+
|
|
25
|
+
from ._settings import _resolve_client
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from agent_framework._agents import SupportsAgentRun
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DXMTContextProvider(ContextProvider):
|
|
34
|
+
"""Recall DXMT memories before a run and remember the exchange after it."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
|
|
37
|
+
DEFAULT_SOURCE_ID: ClassVar[str] = "dxmt"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
source_id: str = DEFAULT_SOURCE_ID,
|
|
42
|
+
*,
|
|
43
|
+
client: DXMTClient | None = None,
|
|
44
|
+
url: str | None = None,
|
|
45
|
+
api_key: str | SecretString | None = None,
|
|
46
|
+
managed_client: bool | None = None,
|
|
47
|
+
env_file_path: str | None = None,
|
|
48
|
+
env_file_encoding: str | None = None,
|
|
49
|
+
application_id: str | None = None,
|
|
50
|
+
agent_id: str | None = None,
|
|
51
|
+
user_id: str | None = None,
|
|
52
|
+
search_application_id: str | None = None,
|
|
53
|
+
search_agent_id: str | None = None,
|
|
54
|
+
search_user_id: str | None = None,
|
|
55
|
+
context_prompt: str | None = None,
|
|
56
|
+
limit: int = 10,
|
|
57
|
+
kind: str | None = None,
|
|
58
|
+
store_user_messages: bool = True,
|
|
59
|
+
store_assistant_messages: bool = True,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Initialize the provider.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
source_id: Identifier of this provider in the session context.
|
|
65
|
+
client: Existing ``DXMTClient``; otherwise one is built from ``url`` / ``api_key`` /
|
|
66
|
+
``DXMT_URL`` / ``DXMT_API_KEY`` and closed with the provider.
|
|
67
|
+
url, api_key, managed_client, env_file_path, env_file_encoding: As on ``DXMTCollection``.
|
|
68
|
+
application_id, agent_id, user_id: The storage scope, stamped as tags on every memory written.
|
|
69
|
+
search_application_id, search_agent_id, search_user_id: The retrieval scope. Memories
|
|
70
|
+
carrying any of these tags are recalled; none set means nothing is recalled.
|
|
71
|
+
context_prompt: Heading placed above the recalled memories.
|
|
72
|
+
limit: How many memories to recall per run.
|
|
73
|
+
kind: Memory kind to write (``fact``, ``preference``, ``event``, ``instruction``, ``note``);
|
|
74
|
+
default lets the server file user turns as ``note``.
|
|
75
|
+
store_user_messages, store_assistant_messages: Which sides of the exchange to remember.
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(source_id)
|
|
78
|
+
resolved, owns = _resolve_client(
|
|
79
|
+
client,
|
|
80
|
+
url=url,
|
|
81
|
+
api_key=api_key,
|
|
82
|
+
managed_client=managed_client,
|
|
83
|
+
env_file_path=env_file_path,
|
|
84
|
+
env_file_encoding=env_file_encoding,
|
|
85
|
+
)
|
|
86
|
+
self.client = resolved
|
|
87
|
+
self.managed_client = owns
|
|
88
|
+
self.application_id = application_id
|
|
89
|
+
self.agent_id = agent_id
|
|
90
|
+
self.user_id = user_id
|
|
91
|
+
self.search_application_id = search_application_id
|
|
92
|
+
self.search_agent_id = search_agent_id
|
|
93
|
+
self.search_user_id = search_user_id
|
|
94
|
+
self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
|
|
95
|
+
self.limit = limit
|
|
96
|
+
self.kind = kind
|
|
97
|
+
self.store_user_messages = store_user_messages
|
|
98
|
+
self.store_assistant_messages = store_assistant_messages
|
|
99
|
+
self._warned_no_search_scope = False
|
|
100
|
+
self._closed = False
|
|
101
|
+
if not (application_id or agent_id or user_id):
|
|
102
|
+
raise ValueError("At least one of application_id, agent_id or user_id is required to scope memories.")
|
|
103
|
+
|
|
104
|
+
# ---- scope as tags --------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _tags(application_id: str | None, agent_id: str | None, user_id: str | None) -> list[str]:
|
|
108
|
+
tags = []
|
|
109
|
+
if user_id:
|
|
110
|
+
tags.append(f"user:{user_id}")
|
|
111
|
+
if agent_id:
|
|
112
|
+
tags.append(f"agent:{agent_id}")
|
|
113
|
+
if application_id:
|
|
114
|
+
tags.append(f"app:{application_id}")
|
|
115
|
+
return tags
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def storage_tags(self) -> list[str]:
|
|
119
|
+
return self._tags(self.application_id, self.agent_id, self.user_id)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def search_tags(self) -> list[str]:
|
|
123
|
+
return self._tags(self.search_application_id, self.search_agent_id, self.search_user_id)
|
|
124
|
+
|
|
125
|
+
# ---- hooks -----------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async def before_run(
|
|
128
|
+
self, *, agent: SupportsAgentRun, session: AgentSession, context: SessionContext, state: dict[str, Any]
|
|
129
|
+
) -> None:
|
|
130
|
+
tags = self.search_tags
|
|
131
|
+
if not tags:
|
|
132
|
+
if not self._warned_no_search_scope:
|
|
133
|
+
self._warned_no_search_scope = True
|
|
134
|
+
logger.warning(
|
|
135
|
+
"DXMTContextProvider has no retrieval scope, so no memories will be recalled. "
|
|
136
|
+
"Set search_user_id, search_agent_id and/or search_application_id."
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
query = "\n".join(m.text for m in context.input_messages if m and m.text and m.text.strip())
|
|
140
|
+
if not query.strip():
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
memories = await self.client.arecall(query, limit=self.limit, tags=tags)
|
|
144
|
+
except DXMTError as exc:
|
|
145
|
+
logger.error("DXMT recall failed: %s", exc)
|
|
146
|
+
return
|
|
147
|
+
lines = [str(m.get("text", "")) for m in memories if m.get("text")]
|
|
148
|
+
if lines:
|
|
149
|
+
context.extend_messages(
|
|
150
|
+
self.source_id, [Message(role="user", contents=[f"{self.context_prompt}\n" + "\n".join(lines)])]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def after_run(
|
|
154
|
+
self, *, agent: SupportsAgentRun, session: AgentSession, context: SessionContext, state: dict[str, Any]
|
|
155
|
+
) -> None:
|
|
156
|
+
messages: list[Message] = list(context.input_messages)
|
|
157
|
+
if context.response and context.response.messages:
|
|
158
|
+
messages.extend(context.response.messages)
|
|
159
|
+
tags = self.storage_tags
|
|
160
|
+
source = f"session:{session.session_id}" if session.session_id else self.agent_id or self.source_id
|
|
161
|
+
for message in messages:
|
|
162
|
+
role = message.role.value if hasattr(message.role, "value") else str(message.role)
|
|
163
|
+
if not message.text or not message.text.strip():
|
|
164
|
+
continue
|
|
165
|
+
if role == "user" and not self.store_user_messages:
|
|
166
|
+
continue
|
|
167
|
+
if role == "assistant" and not self.store_assistant_messages:
|
|
168
|
+
continue
|
|
169
|
+
if role not in {"user", "assistant"}:
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
await self.client.aremember(f"{role}: {message.text.strip()}", kind=self.kind, tags=tags, source=source)
|
|
173
|
+
except DXMTError as exc:
|
|
174
|
+
logger.error("DXMT remember failed: %s", exc)
|
|
175
|
+
|
|
176
|
+
# ---- explicit memory operations ---------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async def remember(
|
|
179
|
+
self, text: str, *, kind: str | None = None, supersedes: list[str] | None = None
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
"""Keep a memory under the storage scope; returns the server's answer (``id``, ``superseded``)."""
|
|
182
|
+
return await self.client.aremember(text, kind=kind or self.kind, tags=self.storage_tags, supersedes=supersedes)
|
|
183
|
+
|
|
184
|
+
async def recall(self, query: str, *, limit: int | None = None) -> list[dict[str, Any]]:
|
|
185
|
+
"""Memories matching ``query`` within the retrieval scope."""
|
|
186
|
+
return await self.client.arecall(query, limit=limit or self.limit, tags=self.search_tags or None)
|
|
187
|
+
|
|
188
|
+
async def forget(self, ids: list[str]) -> int:
|
|
189
|
+
"""Delete memories by id; returns how many there were."""
|
|
190
|
+
forgotten = 0
|
|
191
|
+
for memory_id in ids:
|
|
192
|
+
forgotten += await self.client.aforget(memory_id)
|
|
193
|
+
return forgotten
|
|
194
|
+
|
|
195
|
+
# ---- lifecycle -------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async def __aenter__(self) -> Self:
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
201
|
+
if self.managed_client and not self._closed:
|
|
202
|
+
await self.client.aclose()
|
|
203
|
+
self._closed = True
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Agent Framework portable filters to the server's filter, or a refusal.
|
|
2
|
+
|
|
3
|
+
The server takes one shape, three flat lists of conditions -- every ``must`` holds, at least
|
|
4
|
+
one ``should`` holds, no ``must_not`` holds -- so not every filter tree has a form there. This
|
|
5
|
+
translation is built on the SDK's ``Field`` builder, which produces that shape exactly or raises
|
|
6
|
+
``ValueError`` naming the formula it cannot flatten, and that is re-raised here as
|
|
7
|
+
``NotImplementedError``, which is what the framework's own connectors raise for a portable
|
|
8
|
+
filter they cannot honour. Nothing is approximated.
|
|
9
|
+
|
|
10
|
+
Operators (the framework's ``FilterOperator``), with the server's semantics -- a field holding an
|
|
11
|
+
array matches when any element does, which is what makes the ``contains*`` family natural:
|
|
12
|
+
|
|
13
|
+
================== ==========================================================
|
|
14
|
+
``eq`` ``ne`` ``Equal`` / ``NotEqual``
|
|
15
|
+
``gt`` ``gte`` numeric comparisons; ``between`` is ``gte`` and ``lte``
|
|
16
|
+
``lt`` ``lte``
|
|
17
|
+
``in`` ``not_in`` ``In`` / ``NotIn``
|
|
18
|
+
``is_null`` ``IsNull``; ``is_not_null`` and ``exists`` are ``not IsEmpty``
|
|
19
|
+
``contains`` ``Equal`` on the array field; ``contains_any`` is ``In``;
|
|
20
|
+
``contains_all`` is one ``Equal`` per value, all required
|
|
21
|
+
``starts_with`` ``StartsWith``; ``ends_with`` is ``EndsWith``
|
|
22
|
+
``contains_text`` ``Contains`` (a substring test, not tokenised search)
|
|
23
|
+
================== ==========================================================
|
|
24
|
+
|
|
25
|
+
Field names are the collection definition's logical names and are mapped to storage names;
|
|
26
|
+
the key field is the record id (server key ``id``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import functools
|
|
32
|
+
import operator as op
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from agent_framework import Filter as AFFilter
|
|
36
|
+
from agent_framework import FilterGroup, VectorStoreCollectionDefinition
|
|
37
|
+
from dxmt.filters import Field, Filter
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def translate(expression: Any, definition: VectorStoreCollectionDefinition) -> dict[str, Any] | None:
|
|
41
|
+
"""The server filter for ``expression``, or ``None`` for no filter."""
|
|
42
|
+
if expression is None:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return _node(expression, definition).to_dict()
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise NotImplementedError(f"DXMT has no flat form for this filter: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _storage_name(name: str, definition: VectorStoreCollectionDefinition) -> str:
|
|
51
|
+
field = definition.try_get_field(name)
|
|
52
|
+
if field is None or "." in name:
|
|
53
|
+
raise NotImplementedError("DXMT filters support declared top-level logical fields only.")
|
|
54
|
+
if field.field_type == "vector":
|
|
55
|
+
raise NotImplementedError("Portable DXMT filters do not operate on vector fields.")
|
|
56
|
+
if field.field_type == "key":
|
|
57
|
+
return "id"
|
|
58
|
+
return field.storage_name or field.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _leaf(f: AFFilter, definition: VectorStoreCollectionDefinition) -> Filter:
|
|
62
|
+
field = Field(_storage_name(f.field_name, definition))
|
|
63
|
+
o, v = f.operator, f.value
|
|
64
|
+
if o == "eq":
|
|
65
|
+
return field == v
|
|
66
|
+
if o == "ne":
|
|
67
|
+
return field != v
|
|
68
|
+
if o == "gt":
|
|
69
|
+
return field > v
|
|
70
|
+
if o == "gte":
|
|
71
|
+
return field >= v
|
|
72
|
+
if o == "lt":
|
|
73
|
+
return field < v
|
|
74
|
+
if o == "lte":
|
|
75
|
+
return field <= v
|
|
76
|
+
if o == "between":
|
|
77
|
+
return (field >= v[0]) & (field <= v[1])
|
|
78
|
+
if o == "in":
|
|
79
|
+
return field.is_in(list(v))
|
|
80
|
+
if o == "not_in":
|
|
81
|
+
return field.not_in(list(v))
|
|
82
|
+
if o == "is_null":
|
|
83
|
+
return field.is_null()
|
|
84
|
+
if o in ("is_not_null", "exists"):
|
|
85
|
+
return ~field.is_empty()
|
|
86
|
+
if o == "contains":
|
|
87
|
+
return field == v
|
|
88
|
+
if o == "contains_any":
|
|
89
|
+
return field.is_in(list(v))
|
|
90
|
+
if o == "contains_all":
|
|
91
|
+
values = list(v)
|
|
92
|
+
if not values:
|
|
93
|
+
raise NotImplementedError("contains_all with no values has no meaning on the server.")
|
|
94
|
+
return functools.reduce(op.and_, (field == item for item in values))
|
|
95
|
+
if o == "starts_with":
|
|
96
|
+
return field.starts_with(str(v))
|
|
97
|
+
if o == "ends_with":
|
|
98
|
+
return field.ends_with(str(v))
|
|
99
|
+
if o == "contains_text":
|
|
100
|
+
return field.contains(str(v))
|
|
101
|
+
raise NotImplementedError(f"DXMT does not support portable filter operator '{o}'.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _node(expression: Any, definition: VectorStoreCollectionDefinition) -> Filter:
|
|
105
|
+
if isinstance(expression, FilterGroup):
|
|
106
|
+
parts = [_node(child, definition) for child in expression.filters]
|
|
107
|
+
if not parts:
|
|
108
|
+
raise NotImplementedError("An empty filter group has no server form.")
|
|
109
|
+
if expression.operator == "and":
|
|
110
|
+
return functools.reduce(op.and_, parts)
|
|
111
|
+
if expression.operator == "or":
|
|
112
|
+
return functools.reduce(op.or_, parts)
|
|
113
|
+
# "not" takes exactly one child.
|
|
114
|
+
return ~parts[0]
|
|
115
|
+
if isinstance(expression, AFFilter):
|
|
116
|
+
return _leaf(expression, definition)
|
|
117
|
+
raise TypeError(f"Unexpected filter node {type(expression).__name__}.")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Connection settings and client ownership, in the shape the in-tree connectors use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from agent_framework import SecretString, load_settings
|
|
9
|
+
from dxmt import DXMTClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DXMTSettings(TypedDict, total=False):
|
|
13
|
+
"""Connection settings loaded through Agent Framework's ``load_settings``.
|
|
14
|
+
|
|
15
|
+
Explicit constructor arguments take precedence over an explicitly selected ``.env``
|
|
16
|
+
file, then environment variables prefixed with ``DXMT_``.
|
|
17
|
+
|
|
18
|
+
Keys:
|
|
19
|
+
url: Server URL from ``DXMT_URL`` (``http://host:port``). Defaults to ``http://localhost:9999``.
|
|
20
|
+
api_key: Optional API key from ``DXMT_API_KEY``, held as an AF ``SecretString``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
url: str | None
|
|
24
|
+
api_key: SecretString | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_URL = "http://localhost:9999"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client_kwargs(url: str | None, api_key: SecretString | str | None) -> dict[str, object]:
|
|
31
|
+
parsed = urlparse(url or DEFAULT_URL)
|
|
32
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
33
|
+
raise ValueError(f"DXMT url must look like http://host:port, got {url!r}")
|
|
34
|
+
key = api_key.get_secret_value() if isinstance(api_key, SecretString) else api_key
|
|
35
|
+
return {
|
|
36
|
+
"host": parsed.hostname,
|
|
37
|
+
"port": parsed.port or (443 if parsed.scheme == "https" else 9999),
|
|
38
|
+
"ssl": parsed.scheme == "https",
|
|
39
|
+
"api_key": key,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _create_client(
|
|
44
|
+
*,
|
|
45
|
+
url: str | None,
|
|
46
|
+
api_key: str | SecretString | None,
|
|
47
|
+
env_file_path: str | None,
|
|
48
|
+
env_file_encoding: str | None,
|
|
49
|
+
) -> DXMTClient:
|
|
50
|
+
settings = load_settings(
|
|
51
|
+
DXMTSettings,
|
|
52
|
+
env_prefix="DXMT_",
|
|
53
|
+
url=url,
|
|
54
|
+
api_key=api_key,
|
|
55
|
+
env_file_path=env_file_path,
|
|
56
|
+
env_file_encoding=env_file_encoding,
|
|
57
|
+
)
|
|
58
|
+
return DXMTClient(**_client_kwargs(settings.get("url"), settings.get("api_key"))) # type: ignore[arg-type]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_client(
|
|
62
|
+
client: DXMTClient | None,
|
|
63
|
+
*,
|
|
64
|
+
url: str | None,
|
|
65
|
+
api_key: str | SecretString | None,
|
|
66
|
+
managed_client: bool | None,
|
|
67
|
+
env_file_path: str | None,
|
|
68
|
+
env_file_encoding: str | None,
|
|
69
|
+
) -> tuple[DXMTClient, bool]:
|
|
70
|
+
"""The client and whether this connector closes it.
|
|
71
|
+
|
|
72
|
+
An injected client is returned unchanged and stays caller-owned; url, api_key and the
|
|
73
|
+
``.env`` options are not read. A connector-created client is owned unless
|
|
74
|
+
``managed_client`` is false.
|
|
75
|
+
"""
|
|
76
|
+
if client is not None:
|
|
77
|
+
return client, False
|
|
78
|
+
return (
|
|
79
|
+
_create_client(url=url, api_key=api_key, env_file_path=env_file_path, env_file_encoding=env_file_encoding),
|
|
80
|
+
True if managed_client is None else managed_client,
|
|
81
|
+
)
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""DXMT vector store and collection for Microsoft Agent Framework.
|
|
2
|
+
|
|
3
|
+
A collection is one DXMT table. The record's key is the row id (stored as a string; ``int``
|
|
4
|
+
and ``UUID`` keys are converted on the way in and back on the way out), its one vector field
|
|
5
|
+
is the row's vector, and its data fields are the payload under their storage names. A data
|
|
6
|
+
field marked ``is_full_text_indexed`` gets the table's BM25 index, and then
|
|
7
|
+
``search(values, search_type="keyword_hybrid", additional_property_name=<that field>)`` is
|
|
8
|
+
the server's hybrid search; this is the second in-tree-shaped connector to offer it. With
|
|
9
|
+
``server_embedding`` the table names an embedding model and a record whose vector is ``None``
|
|
10
|
+
is embedded by the server from ``server_embedding_field``; a search without a local
|
|
11
|
+
embedding generator then sends ``values`` as text.
|
|
12
|
+
|
|
13
|
+
Scores are the server's similarities (1.0 is identical under cosine; larger is better) and
|
|
14
|
+
``score_threshold`` is in those units. Supplied clients remain caller-owned.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from typing import Any, ClassVar, Generic, cast
|
|
21
|
+
from uuid import UUID
|
|
22
|
+
|
|
23
|
+
from agent_framework import (
|
|
24
|
+
BaseVectorCollection,
|
|
25
|
+
BaseVectorSearch,
|
|
26
|
+
BaseVectorStore,
|
|
27
|
+
SearchResults,
|
|
28
|
+
SecretString,
|
|
29
|
+
VectorStoreCollectionDefinition,
|
|
30
|
+
)
|
|
31
|
+
from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException
|
|
32
|
+
from dxmt import DXMTClient
|
|
33
|
+
from dxmt.exceptions import DXMTError
|
|
34
|
+
from typing_extensions import TypeVar
|
|
35
|
+
|
|
36
|
+
from ._filters import translate
|
|
37
|
+
from ._settings import _resolve_client
|
|
38
|
+
|
|
39
|
+
KeyT = TypeVar("KeyT", default=Any)
|
|
40
|
+
ModelT = TypeVar("ModelT", default=Any)
|
|
41
|
+
|
|
42
|
+
_DISTANCES = {
|
|
43
|
+
"DEFAULT": "cosine",
|
|
44
|
+
"cosine_similarity": "cosine",
|
|
45
|
+
"dot_prod": "dot",
|
|
46
|
+
"euclidean_distance": "euclidean",
|
|
47
|
+
}
|
|
48
|
+
_BATCH_SIZE = 500
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _not_found(exc: Exception) -> bool:
|
|
52
|
+
return "TABLE_NOT_FOUND" in str(exc) or "NOT_FOUND" in str(exc)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DXMTCollection(BaseVectorCollection[KeyT, ModelT], BaseVectorSearch[KeyT, ModelT], Generic[KeyT, ModelT]):
|
|
56
|
+
"""One DXMT table as an Agent Framework vector collection."""
|
|
57
|
+
|
|
58
|
+
supported_key_types: ClassVar[set[str] | None] = {"int", "str", "UUID"}
|
|
59
|
+
supported_vector_types: ClassVar[set[str] | None] = {"float", "float16", "float32", "float64"}
|
|
60
|
+
supported_search_types: ClassVar[set[str]] = {"vector", "keyword_hybrid"} # type: ignore[assignment]
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
record_type: type[ModelT],
|
|
65
|
+
*,
|
|
66
|
+
definition: VectorStoreCollectionDefinition | None = None,
|
|
67
|
+
collection_name: str | None = None,
|
|
68
|
+
embedding_generator: Any = None,
|
|
69
|
+
client: DXMTClient | None = None,
|
|
70
|
+
url: str | None = None,
|
|
71
|
+
api_key: str | SecretString | None = None,
|
|
72
|
+
managed_client: bool | None = None,
|
|
73
|
+
env_file_path: str | None = None,
|
|
74
|
+
env_file_encoding: str | None = None,
|
|
75
|
+
server_embedding: Mapping[str, Any] | str | None = None,
|
|
76
|
+
server_embedding_field: str | None = None,
|
|
77
|
+
text_analyzer: str = "standard",
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize the collection.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
record_type: Registered application model, or dict with an explicit definition.
|
|
83
|
+
definition: Explicit dictionary schema.
|
|
84
|
+
collection_name: Table name, overriding the model's collection name.
|
|
85
|
+
embedding_generator: Default local embedding client.
|
|
86
|
+
client: Existing ``DXMTClient``. When supplied, url, api_key and the .env options are
|
|
87
|
+
ignored and the client remains caller-owned.
|
|
88
|
+
url: Server URL override (``http://host:port``); otherwise ``DXMT_URL``, then localhost:9999.
|
|
89
|
+
api_key: API key override; otherwise ``DXMT_API_KEY``.
|
|
90
|
+
managed_client: Whether to close a connector-created client. Defaults to True.
|
|
91
|
+
env_file_path: Optional .env file to load before process environment variables.
|
|
92
|
+
env_file_encoding: Encoding for the .env file.
|
|
93
|
+
server_embedding: ``{"model": ..., "base_url": ..., "api_key_env": ...}`` to have the
|
|
94
|
+
server embed with that model, or ``"server"`` for the model the server itself was
|
|
95
|
+
started with; the table is created accordingly, and a record whose vector is
|
|
96
|
+
``None`` is embedded from ``server_embedding_field``.
|
|
97
|
+
server_embedding_field: The data field whose text the server embeds. Defaults to the
|
|
98
|
+
full-text field when there is exactly one.
|
|
99
|
+
text_analyzer: ``standard`` or ``chinese`` for the BM25 index.
|
|
100
|
+
"""
|
|
101
|
+
super().__init__(
|
|
102
|
+
record_type,
|
|
103
|
+
definition=definition,
|
|
104
|
+
collection_name=collection_name,
|
|
105
|
+
embedding_generator=embedding_generator,
|
|
106
|
+
)
|
|
107
|
+
resolved, owns = _resolve_client(
|
|
108
|
+
client,
|
|
109
|
+
url=url,
|
|
110
|
+
api_key=api_key,
|
|
111
|
+
managed_client=managed_client,
|
|
112
|
+
env_file_path=env_file_path,
|
|
113
|
+
env_file_encoding=env_file_encoding,
|
|
114
|
+
)
|
|
115
|
+
self.client = resolved
|
|
116
|
+
self.managed_client = owns
|
|
117
|
+
self.server_embedding: dict[str, Any] | str | None = (
|
|
118
|
+
"server" if server_embedding == "server" else dict(server_embedding) if server_embedding else None
|
|
119
|
+
)
|
|
120
|
+
self.text_analyzer = text_analyzer
|
|
121
|
+
self._closed = False
|
|
122
|
+
self._full_text_field = self._resolve_full_text_field()
|
|
123
|
+
self.server_embedding_field = server_embedding_field or (
|
|
124
|
+
self._full_text_field.name if self._full_text_field is not None else None
|
|
125
|
+
)
|
|
126
|
+
if self.server_embedding is not None and self.server_embedding_field is None:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"server_embedding needs server_embedding_field: the data field whose text the server embeds."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# ---- model -----------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _validate_data_model(self) -> None:
|
|
134
|
+
super()._validate_data_model()
|
|
135
|
+
if self.definition.key_field.is_auto_generated:
|
|
136
|
+
raise NotImplementedError("DXMT requires application-provided record ids; it does not generate keys.")
|
|
137
|
+
vectors = self.definition.vector_fields
|
|
138
|
+
if len(vectors) != 1:
|
|
139
|
+
raise NotImplementedError("A DXMT collection holds exactly one vector field.")
|
|
140
|
+
field = vectors[0]
|
|
141
|
+
if not isinstance(field.dimensions, int) or isinstance(field.dimensions, bool):
|
|
142
|
+
raise TypeError("DXMT vector dimensions must be an integer.")
|
|
143
|
+
if field.distance_function not in _DISTANCES:
|
|
144
|
+
raise NotImplementedError(f"DXMT distance function '{field.distance_function}' is not supported.")
|
|
145
|
+
if field.index_kind not in {"default", "hnsw"}:
|
|
146
|
+
raise NotImplementedError(f"DXMT index kind '{field.index_kind}' is not supported.")
|
|
147
|
+
for data in self.definition.data_fields:
|
|
148
|
+
name = data.storage_name or data.name
|
|
149
|
+
if not name or "." in name or name in {"id", "content"}:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"DXMT payload storage name {name!r} is reserved or contains a dot; choose another storage_name."
|
|
152
|
+
)
|
|
153
|
+
if any(f.provider_annotations for f in self.definition.fields):
|
|
154
|
+
raise NotImplementedError("DXMT has no provider annotations.")
|
|
155
|
+
|
|
156
|
+
def _resolve_full_text_field(self) -> Any:
|
|
157
|
+
indexed = [f for f in self.definition.data_fields if f.is_full_text_indexed]
|
|
158
|
+
if len(indexed) > 1:
|
|
159
|
+
raise NotImplementedError("DXMT indexes one full-text field per table.")
|
|
160
|
+
return indexed[0] if indexed else None
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def _vector_field(self) -> Any:
|
|
164
|
+
return self.definition.vector_fields[0]
|
|
165
|
+
|
|
166
|
+
def _storage(self, field: Any) -> str:
|
|
167
|
+
return field.storage_name or field.name
|
|
168
|
+
|
|
169
|
+
def _key_to_id(self, key: Any) -> str:
|
|
170
|
+
return str(key)
|
|
171
|
+
|
|
172
|
+
def _id_to_key(self, value: str) -> KeyT:
|
|
173
|
+
kind = self.definition.key_field.type_
|
|
174
|
+
if kind == "int":
|
|
175
|
+
return cast(KeyT, int(value))
|
|
176
|
+
if kind == "UUID":
|
|
177
|
+
return cast(KeyT, UUID(value))
|
|
178
|
+
return cast(KeyT, value)
|
|
179
|
+
|
|
180
|
+
# ---- lifecycle -------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
async def collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> bool:
|
|
183
|
+
try:
|
|
184
|
+
await self.client.aget_table(self.collection_name)
|
|
185
|
+
return True
|
|
186
|
+
except DXMTError as exc:
|
|
187
|
+
if _not_found(exc):
|
|
188
|
+
return False
|
|
189
|
+
raise IntegrationException(f"DXMT table lookup failed: {exc}") from exc
|
|
190
|
+
|
|
191
|
+
async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> None:
|
|
192
|
+
if await self.collection_exists():
|
|
193
|
+
return
|
|
194
|
+
field = self._vector_field
|
|
195
|
+
text_fields = None
|
|
196
|
+
if self._full_text_field is not None:
|
|
197
|
+
# The index field is called `content` on every table: the server's full-text schema
|
|
198
|
+
# has fixed names and only `content` is the BM25-ranked body. The source is the payload key.
|
|
199
|
+
text_fields = [
|
|
200
|
+
{
|
|
201
|
+
"name": "content",
|
|
202
|
+
"source": {"metadata_field": self._storage(self._full_text_field)},
|
|
203
|
+
"analyzer": self.text_analyzer,
|
|
204
|
+
"boost": 1.0,
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
try:
|
|
208
|
+
# With a server-side model the server decides the width; "server" means the model it
|
|
209
|
+
# was started with, which a table takes when it names neither a dimension nor a model.
|
|
210
|
+
await self.client.acreate_table(
|
|
211
|
+
name=self.collection_name,
|
|
212
|
+
dimension=None if self.server_embedding is not None else cast(int, field.dimensions),
|
|
213
|
+
distance_metric=_DISTANCES[cast(str, field.distance_function)],
|
|
214
|
+
enable_full_text_search=text_fields is not None,
|
|
215
|
+
text_fields=text_fields,
|
|
216
|
+
embedding=self.server_embedding if isinstance(self.server_embedding, dict) else None,
|
|
217
|
+
)
|
|
218
|
+
except DXMTError as exc:
|
|
219
|
+
raise IntegrationException(f"DXMT could not create table '{self.collection_name}': {exc}") from exc
|
|
220
|
+
|
|
221
|
+
async def ensure_collection_deleted(self, *, operation_options: Mapping[str, Any] | None = None) -> None:
|
|
222
|
+
try:
|
|
223
|
+
await self.client.adelete_table(self.collection_name)
|
|
224
|
+
except DXMTError as exc:
|
|
225
|
+
if not _not_found(exc):
|
|
226
|
+
raise IntegrationException(f"DXMT could not delete table '{self.collection_name}': {exc}") from exc
|
|
227
|
+
|
|
228
|
+
async def aclose(self) -> None:
|
|
229
|
+
if self.managed_client and not self._closed:
|
|
230
|
+
await self.client.aclose()
|
|
231
|
+
self._closed = True
|
|
232
|
+
|
|
233
|
+
async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
|
|
234
|
+
await self.aclose()
|
|
235
|
+
|
|
236
|
+
# ---- conversion ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _serialize_dicts_to_store_models(
|
|
239
|
+
self, records: Sequence[dict[str, Any]], *, context: Mapping[str, Any] | None = None
|
|
240
|
+
) -> Sequence[dict[str, Any]]:
|
|
241
|
+
key_name = self.definition.key_field_storage_name
|
|
242
|
+
vector_name = self._storage(self._vector_field)
|
|
243
|
+
rows: list[dict[str, Any]] = []
|
|
244
|
+
for record in records:
|
|
245
|
+
payload = {self._storage(f): record.get(self._storage(f)) for f in self.definition.data_fields}
|
|
246
|
+
row: dict[str, Any] = {"id": self._key_to_id(record[key_name]), "payload": payload}
|
|
247
|
+
vector = record.get(vector_name)
|
|
248
|
+
if vector is not None:
|
|
249
|
+
row["vector"] = [float(x) for x in vector]
|
|
250
|
+
elif self.server_embedding is not None and self.server_embedding_field is not None:
|
|
251
|
+
source = self.definition.try_get_field(self.server_embedding_field)
|
|
252
|
+
text = payload.get(self._storage(source)) if source is not None else None
|
|
253
|
+
if not isinstance(text, str) or not text:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Record {row['id']!r} has no vector and no text in '{self.server_embedding_field}' for the server to embed."
|
|
256
|
+
)
|
|
257
|
+
row["text"] = text
|
|
258
|
+
else:
|
|
259
|
+
raise ValueError(f"Record {row['id']!r} has no vector; set an embedding generator or server_embedding.")
|
|
260
|
+
rows.append(row)
|
|
261
|
+
return rows
|
|
262
|
+
|
|
263
|
+
def _deserialize_store_models_to_dicts(
|
|
264
|
+
self, records: Sequence[Any], *, context: Mapping[str, Any] | None = None
|
|
265
|
+
) -> Sequence[dict[str, Any]]:
|
|
266
|
+
key_name = self.definition.key_field_storage_name
|
|
267
|
+
vector_name = self._storage(self._vector_field)
|
|
268
|
+
out: list[dict[str, Any]] = []
|
|
269
|
+
for row in records:
|
|
270
|
+
if not isinstance(row, Mapping) or "id" not in row:
|
|
271
|
+
raise IntegrationInvalidResponseException("DXMT rows must be mappings with an id.")
|
|
272
|
+
payload = row.get("payload") or row.get("metadata") or {}
|
|
273
|
+
record: dict[str, Any] = {
|
|
274
|
+
self._storage(f): payload.get(self._storage(f)) for f in self.definition.data_fields
|
|
275
|
+
}
|
|
276
|
+
record[key_name] = self._id_to_key(str(row["id"]))
|
|
277
|
+
vector = row.get("vector")
|
|
278
|
+
if vector:
|
|
279
|
+
record[vector_name] = list(vector)
|
|
280
|
+
out.append(record)
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
# ---- CRUD ------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
async def _inner_upsert(
|
|
286
|
+
self, records: Sequence[Any], *, operation_options: Mapping[str, Any] | None = None
|
|
287
|
+
) -> Sequence[KeyT]:
|
|
288
|
+
if not records:
|
|
289
|
+
return []
|
|
290
|
+
try:
|
|
291
|
+
for start in range(0, len(records), _BATCH_SIZE):
|
|
292
|
+
await self.client.ainsert_records(self.collection_name, list(records[start : start + _BATCH_SIZE]))
|
|
293
|
+
except DXMTError as exc:
|
|
294
|
+
raise IntegrationException(f"DXMT upsert failed: {exc}") from exc
|
|
295
|
+
return [self._id_to_key(row["id"]) for row in records]
|
|
296
|
+
|
|
297
|
+
async def _inner_get(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
keys: Sequence[KeyT] | None = None,
|
|
301
|
+
filter: Any = None,
|
|
302
|
+
top: int = 10,
|
|
303
|
+
skip: int = 0,
|
|
304
|
+
order_by: Mapping[str, bool] | None = None,
|
|
305
|
+
include_vectors: bool = False,
|
|
306
|
+
operation_options: Mapping[str, Any] | None = None,
|
|
307
|
+
) -> Sequence[Any] | None:
|
|
308
|
+
if order_by:
|
|
309
|
+
raise NotImplementedError("DXMT ordered retrieval is not supported. Omit order_by.")
|
|
310
|
+
try:
|
|
311
|
+
if keys is not None:
|
|
312
|
+
if skip:
|
|
313
|
+
raise ValueError("DXMT key retrieval cannot be combined with skip.")
|
|
314
|
+
ids = [self._key_to_id(k) for k in keys]
|
|
315
|
+
rows: list[dict[str, Any]] = []
|
|
316
|
+
for start in range(0, len(ids), _BATCH_SIZE):
|
|
317
|
+
rows.extend(await self.client.aget_records(self.collection_name, ids[start : start + _BATCH_SIZE]))
|
|
318
|
+
if not include_vectors:
|
|
319
|
+
for row in rows:
|
|
320
|
+
row.pop("vector", None)
|
|
321
|
+
return rows
|
|
322
|
+
server_filter = translate(filter, self.definition)
|
|
323
|
+
rows = []
|
|
324
|
+
seen = 0
|
|
325
|
+
async for row in self.client.ascroll(
|
|
326
|
+
self.collection_name,
|
|
327
|
+
filter=server_filter,
|
|
328
|
+
batch_size=min(_BATCH_SIZE, skip + top),
|
|
329
|
+
with_vector=include_vectors,
|
|
330
|
+
):
|
|
331
|
+
if seen < skip:
|
|
332
|
+
seen += 1
|
|
333
|
+
continue
|
|
334
|
+
rows.append(row)
|
|
335
|
+
if len(rows) >= top:
|
|
336
|
+
break
|
|
337
|
+
return rows
|
|
338
|
+
except DXMTError as exc:
|
|
339
|
+
if _not_found(exc):
|
|
340
|
+
return []
|
|
341
|
+
raise IntegrationException(f"DXMT get failed: {exc}") from exc
|
|
342
|
+
|
|
343
|
+
async def _inner_delete(self, keys: Sequence[KeyT], *, operation_options: Mapping[str, Any] | None = None) -> None:
|
|
344
|
+
ids = [self._key_to_id(k) for k in keys]
|
|
345
|
+
try:
|
|
346
|
+
for start in range(0, len(ids), _BATCH_SIZE):
|
|
347
|
+
await self.client.adelete_records(self.collection_name, ids[start : start + _BATCH_SIZE])
|
|
348
|
+
except DXMTError as exc:
|
|
349
|
+
if not _not_found(exc):
|
|
350
|
+
raise IntegrationException(f"DXMT delete failed: {exc}") from exc
|
|
351
|
+
|
|
352
|
+
# ---- search ----------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
async def _inner_search(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
search_type: str,
|
|
358
|
+
filter: Any = None,
|
|
359
|
+
values: Any | None = None,
|
|
360
|
+
vector: Any | None = None,
|
|
361
|
+
top: int = 3,
|
|
362
|
+
skip: int = 0,
|
|
363
|
+
include_vectors: bool = False,
|
|
364
|
+
vector_property_name: str | None = None,
|
|
365
|
+
additional_property_name: str | None = None,
|
|
366
|
+
score_threshold: float | None = None,
|
|
367
|
+
operation_options: Mapping[str, Any] | None = None,
|
|
368
|
+
) -> SearchResults[Any]:
|
|
369
|
+
server_filter = translate(filter, self.definition)
|
|
370
|
+
if score_threshold is not None and (
|
|
371
|
+
isinstance(score_threshold, bool) or not isinstance(score_threshold, (int, float))
|
|
372
|
+
):
|
|
373
|
+
raise ValueError("DXMT score_threshold must be a number.")
|
|
374
|
+
text = values if isinstance(values, str) else None
|
|
375
|
+
query_vector = [float(x) for x in vector] if vector is not None else None
|
|
376
|
+
if search_type == "keyword_hybrid":
|
|
377
|
+
if additional_property_name is not None:
|
|
378
|
+
field = self.definition.try_get_field(additional_property_name)
|
|
379
|
+
if field is None or self._full_text_field is None or field.name != self._full_text_field.name:
|
|
380
|
+
raise NotImplementedError(
|
|
381
|
+
"DXMT keyword-hybrid search runs over the one full-text indexed field of the collection."
|
|
382
|
+
)
|
|
383
|
+
if self._full_text_field is None:
|
|
384
|
+
raise NotImplementedError("Keyword-hybrid search needs a data field marked is_full_text_indexed.")
|
|
385
|
+
if text is None:
|
|
386
|
+
raise ValueError("Keyword-hybrid search needs string values.")
|
|
387
|
+
try:
|
|
388
|
+
hits = await self.client.ahybrid_search(
|
|
389
|
+
self.collection_name, vector=query_vector, text=text, k=top + skip, filter=server_filter
|
|
390
|
+
)
|
|
391
|
+
except DXMTError as exc:
|
|
392
|
+
raise IntegrationException(f"DXMT hybrid search failed: {exc}") from exc
|
|
393
|
+
hits = hits[skip:]
|
|
394
|
+
else:
|
|
395
|
+
if query_vector is None and (text is None or self.server_embedding is None):
|
|
396
|
+
raise ValueError(
|
|
397
|
+
"DXMT vector search requires a vector, an embedding generator, or server_embedding with string values."
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
hits = await self.client.asearch(
|
|
401
|
+
self.collection_name,
|
|
402
|
+
vector=query_vector,
|
|
403
|
+
text=text if query_vector is None else None,
|
|
404
|
+
k=top,
|
|
405
|
+
offset=skip,
|
|
406
|
+
filter=server_filter,
|
|
407
|
+
score_threshold=score_threshold,
|
|
408
|
+
)
|
|
409
|
+
except DXMTError as exc:
|
|
410
|
+
raise IntegrationException(f"DXMT search failed: {exc}") from exc
|
|
411
|
+
if score_threshold is not None:
|
|
412
|
+
hits = [h for h in hits if float(h.get("score", 0.0)) >= score_threshold]
|
|
413
|
+
if include_vectors and hits and any(h.get("vector") is None for h in hits):
|
|
414
|
+
# A search hit carries `vector: null`; the rows have it.
|
|
415
|
+
rows = await self.client.aget_records(self.collection_name, [h["id"] for h in hits])
|
|
416
|
+
vectors = {r["id"]: r.get("vector") for r in rows}
|
|
417
|
+
for h in hits:
|
|
418
|
+
h["vector"] = vectors.get(h["id"])
|
|
419
|
+
return SearchResults(hits)
|
|
420
|
+
|
|
421
|
+
def _get_record_from_result(self, result: Any) -> Any:
|
|
422
|
+
if not isinstance(result, Mapping):
|
|
423
|
+
raise IntegrationInvalidResponseException("Expected a DXMT hit mapping.")
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def _get_score_from_result(self, result: Any) -> float | None:
|
|
427
|
+
score = self._get_record_from_result(result).get("score")
|
|
428
|
+
return float(score) if score is not None else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class DXMTStore(BaseVectorStore):
|
|
432
|
+
"""A DXMT server as an Agent Framework vector store; its tables are the collections."""
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
*,
|
|
437
|
+
client: DXMTClient | None = None,
|
|
438
|
+
url: str | None = None,
|
|
439
|
+
api_key: str | SecretString | None = None,
|
|
440
|
+
embedding_generator: Any = None,
|
|
441
|
+
managed_client: bool | None = None,
|
|
442
|
+
env_file_path: str | None = None,
|
|
443
|
+
env_file_encoding: str | None = None,
|
|
444
|
+
) -> None:
|
|
445
|
+
resolved, owns = _resolve_client(
|
|
446
|
+
client,
|
|
447
|
+
url=url,
|
|
448
|
+
api_key=api_key,
|
|
449
|
+
managed_client=managed_client,
|
|
450
|
+
env_file_path=env_file_path,
|
|
451
|
+
env_file_encoding=env_file_encoding,
|
|
452
|
+
)
|
|
453
|
+
super().__init__(embedding_generator=embedding_generator, managed_client=owns)
|
|
454
|
+
self.client = resolved
|
|
455
|
+
self._closed = False
|
|
456
|
+
|
|
457
|
+
def get_collection(
|
|
458
|
+
self,
|
|
459
|
+
record_type: type[ModelT],
|
|
460
|
+
*,
|
|
461
|
+
definition: VectorStoreCollectionDefinition | None = None,
|
|
462
|
+
collection_name: str | None = None,
|
|
463
|
+
embedding_generator: Any = None,
|
|
464
|
+
**kwargs: Any,
|
|
465
|
+
) -> DXMTCollection[Any, ModelT]:
|
|
466
|
+
return DXMTCollection(
|
|
467
|
+
record_type,
|
|
468
|
+
definition=definition,
|
|
469
|
+
collection_name=collection_name,
|
|
470
|
+
embedding_generator=embedding_generator or self.embedding_generator,
|
|
471
|
+
client=self.client,
|
|
472
|
+
managed_client=False,
|
|
473
|
+
**kwargs,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
async def list_collection_names(self, *, operation_options: Mapping[str, Any] | None = None) -> Sequence[str]:
|
|
477
|
+
try:
|
|
478
|
+
tables = await self.client.alist_tables()
|
|
479
|
+
except DXMTError as exc:
|
|
480
|
+
raise IntegrationException(f"DXMT could not list tables: {exc}") from exc
|
|
481
|
+
return [t["name"] for t in tables]
|
|
482
|
+
|
|
483
|
+
async def _inner_ensure_collection_deleted(
|
|
484
|
+
self, collection_name: str, *, operation_options: Mapping[str, Any] | None = None
|
|
485
|
+
) -> None:
|
|
486
|
+
try:
|
|
487
|
+
await self.client.adelete_table(collection_name)
|
|
488
|
+
except DXMTError as exc:
|
|
489
|
+
if not _not_found(exc):
|
|
490
|
+
raise IntegrationException(f"DXMT could not delete table '{collection_name}': {exc}") from exc
|
|
491
|
+
|
|
492
|
+
async def aclose(self) -> None:
|
|
493
|
+
if self.managed_client and not self._closed:
|
|
494
|
+
await self.client.aclose()
|
|
495
|
+
self._closed = True
|
|
496
|
+
|
|
497
|
+
async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
|
|
498
|
+
await self.aclose()
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-framework-dxmt
|
|
3
|
+
Version: 1.6.0
|
|
4
|
+
Summary: DXMT vector store and memory context provider for Microsoft Agent Framework
|
|
5
|
+
Project-URL: Homepage, https://github.com/dxmt/dxmt
|
|
6
|
+
Project-URL: Source, https://github.com/dxmt/dxmt/tree/main/vectordb/clients/agent-framework-dxmt
|
|
7
|
+
Project-URL: Issues, https://github.com/dxmt/dxmt/issues
|
|
8
|
+
Author-email: DXMT Team <contact@dxmt.online>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-framework,agent-memory,context-provider,dxmt,microsoft-agent-framework,vector-store
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: agent-framework-core<2,>=1.19.0
|
|
23
|
+
Requires-Dist: dxmt-python-sdk<2.0.0,>=1.6.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# agent-framework-dxmt
|
|
32
|
+
|
|
33
|
+
[DXMT](https://github.com/dxmt/dxmt), the open-source memory database for personal agents,
|
|
34
|
+
for [Microsoft Agent Framework](https://github.com/microsoft/agent-framework): a vector store
|
|
35
|
+
with keyword-hybrid search, and memory as a context provider.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install agent-framework-dxmt
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A DXMT server has to be running (`vectordb serve`, or
|
|
42
|
+
`docker run -p 127.0.0.1:9999:9999 -v dxmt:/data dxmtai/dxmt`). Connection settings are
|
|
43
|
+
`DXMT_URL` (default `http://localhost:9999`) and `DXMT_API_KEY`, or the `url` / `api_key`
|
|
44
|
+
arguments, resolved through Agent Framework's `load_settings` like every other connector.
|
|
45
|
+
|
|
46
|
+
## Vector store
|
|
47
|
+
|
|
48
|
+
`DXMTCollection` and `DXMTStore` implement `BaseVectorCollection` / `BaseVectorSearch` and
|
|
49
|
+
`BaseVectorStore`. A collection is one DXMT table; the record's single vector field is the
|
|
50
|
+
row's vector and its data fields are the payload.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from typing import Annotated
|
|
55
|
+
|
|
56
|
+
from agent_framework import Filter, VectorStoreField, vectorstoremodel
|
|
57
|
+
from agent_framework_dxmt import DXMTStore
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@vectorstoremodel
|
|
61
|
+
@dataclass
|
|
62
|
+
class Document:
|
|
63
|
+
id: Annotated[str, VectorStoreField("key")]
|
|
64
|
+
title: Annotated[str, VectorStoreField("data", is_full_text_indexed=True)]
|
|
65
|
+
year: Annotated[int, VectorStoreField("data", is_indexed=True)]
|
|
66
|
+
embedding: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async with DXMTStore() as store:
|
|
70
|
+
collection = store.get_collection(Document, collection_name="documents")
|
|
71
|
+
await collection.ensure_collection_exists()
|
|
72
|
+
await collection.upsert([Document("1", "Hello DXMT", 2026, [1.0, 0.0, 0.0])], generate_vectors=False)
|
|
73
|
+
results = await collection.search(vector=[1.0, 0.0, 0.0], filter=Filter("year", "gte", 2020), top=3)
|
|
74
|
+
async for item in results:
|
|
75
|
+
print(item["record"].title, item["score"])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
What is specific to DXMT:
|
|
79
|
+
|
|
80
|
+
- **Keyword-hybrid search.** A data field marked `is_full_text_indexed` gets the table's BM25
|
|
81
|
+
index, and `collection.search("hello", search_type="keyword_hybrid", additional_property_name="title")`
|
|
82
|
+
is the server's hybrid search (vector and keywords fused on the server). Most in-tree
|
|
83
|
+
connectors declare `{"vector"}` only.
|
|
84
|
+
- **Server-side embedding.** `DXMTCollection(..., server_embedding={"model": "text-embedding-3-small"},
|
|
85
|
+
server_embedding_field="title")` has the server embed records whose vector is `None` and
|
|
86
|
+
queries given as text, so no embedding client runs in the agent's process.
|
|
87
|
+
- **Filters.** Every portable operator (`eq`, `ne`, ranges, `between`, `in`, `not_in`,
|
|
88
|
+
`is_null`, `is_not_null`, `exists`, `contains`, `contains_any`, `contains_all`,
|
|
89
|
+
`starts_with`, `ends_with`, `contains_text`) and `and` / `or` / `not` groups, translated
|
|
90
|
+
exactly to the server's flat filter; a shape the server has no flat form for (`(a and b) or c`)
|
|
91
|
+
raises `NotImplementedError` rather than being approximated.
|
|
92
|
+
- Scores are similarities (1.0 is identical under cosine); `score_threshold` is in those units.
|
|
93
|
+
One vector field per collection; `cosine_similarity` (default), `dot_prod`, `euclidean_distance`.
|
|
94
|
+
|
|
95
|
+
## Memory as a context provider
|
|
96
|
+
|
|
97
|
+
`DXMTContextProvider` recalls memories before a run (by meaning *and* keyword) and remembers
|
|
98
|
+
the exchange after it, using DXMT's own memory API. Scope is tags: memories are written under
|
|
99
|
+
`user_id` / `agent_id` / `application_id` and recalled by the separate `search_*` ids, the
|
|
100
|
+
split the mem0 provider uses so a memory written under a shared agent is not read back for an
|
|
101
|
+
unrelated user by accident.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from agent_framework import Agent
|
|
105
|
+
from agent_framework_dxmt import DXMTContextProvider
|
|
106
|
+
|
|
107
|
+
memory = DXMTContextProvider(user_id="alice", search_user_id="alice")
|
|
108
|
+
agent = Agent(chat_client, instructions="...", context_providers=[memory])
|
|
109
|
+
await agent.run("I prefer answers in Chinese.")
|
|
110
|
+
await agent.run("How should you answer me?") # the preference is recalled into the context
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`memory.remember(text, kind=..., supersedes=[...])`, `memory.recall(query)` and
|
|
114
|
+
`memory.forget(ids)` expose the three operations directly. A memory said twice is kept once;
|
|
115
|
+
a new fact can supersede an old one, which is kept as history and left out of recall. The
|
|
116
|
+
same store is what every other agent on the machine sees over MCP and REST.
|
|
117
|
+
|
|
118
|
+
## Tests
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pytest # unit tests, client mocked
|
|
122
|
+
DXMT_TEST_URL=http://127.0.0.1:9999 pytest -m integration # against a server
|
|
123
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agent_framework_dxmt/__init__.py,sha256=vc_ETqQXIiTchKXVMNEbEYUautuhq2__s5O4Y8Cf91g,506
|
|
2
|
+
agent_framework_dxmt/_context_provider.py,sha256=T0wM8VHzUexxRacuwq583MIQ0GwWK0HCyeFJZxCBwJI,9184
|
|
3
|
+
agent_framework_dxmt/_filters.py,sha256=7WKV7WfYuyKUGx7HuCXCT9ELAn1ddByb6ywu3RNXJpw,4925
|
|
4
|
+
agent_framework_dxmt/_settings.py,sha256=iPRyMe7SeHnHmb5oPOXg_X91j4u-U9NXJqupYV8ZDcY,2691
|
|
5
|
+
agent_framework_dxmt/_vector_store.py,sha256=uWPe4VpP33XKH1UKJV_srY4RExCSRRbh1vDZizgu29A,22726
|
|
6
|
+
agent_framework_dxmt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
agent_framework_dxmt-1.6.0.dist-info/METADATA,sha256=ypia6p3rP9Tq192MD1PtS8GEosO9zsbPqAIOkkU3hao,5821
|
|
8
|
+
agent_framework_dxmt-1.6.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
agent_framework_dxmt-1.6.0.dist-info/licenses/LICENSE,sha256=VM3yU2vC82ewJ7c3oiE0Q4GyImTppYtuM2kUGshveL8,11430
|
|
10
|
+
agent_framework_dxmt-1.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 上海点线面体信息科技有限公司 (Shanghai Dianxianmianti Information Technology Co., Ltd.)
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|