xmemory-temporal 1.0.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.
- xmemory_temporal/__init__.py +61 -0
- xmemory_temporal/activities.py +186 -0
- xmemory_temporal/client_factory.py +43 -0
- xmemory_temporal/config.py +96 -0
- xmemory_temporal/dto.py +160 -0
- xmemory_temporal/errors.py +235 -0
- xmemory_temporal/interceptor.py +182 -0
- xmemory_temporal/plugin.py +89 -0
- xmemory_temporal/protocol.py +49 -0
- xmemory_temporal/py.typed +0 -0
- xmemory_temporal/workflow_api.py +320 -0
- xmemory_temporal-1.0.0.dist-info/METADATA +313 -0
- xmemory_temporal-1.0.0.dist-info/RECORD +17 -0
- xmemory_temporal-1.0.0.dist-info/WHEEL +5 -0
- xmemory_temporal-1.0.0.dist-info/licenses/LICENSE +21 -0
- xmemory_temporal-1.0.0.dist-info/licenses/NOTICE +27 -0
- xmemory_temporal-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Temporal plugin for xmemory — durable agent memory as Temporal Activities.
|
|
2
|
+
|
|
3
|
+
Register ``XmemoryPlugin`` on your Temporal ``Client`` (the Worker inherits its
|
|
4
|
+
client's plugins) — or on the ``Worker`` — but **never both**: registering twice
|
|
5
|
+
adds the activities twice and the worker crashes at boot with "More than one
|
|
6
|
+
activity named xmemory_read". Then call ``xmemory_for_workflow`` inside a
|
|
7
|
+
workflow to read and write memory through replay-safe activities.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from xmemory_temporal.activities import (
|
|
11
|
+
ACTIVITY_READ,
|
|
12
|
+
ACTIVITY_WRITE,
|
|
13
|
+
ACTIVITY_WRITE_START,
|
|
14
|
+
ACTIVITY_WRITE_STATUS,
|
|
15
|
+
XmemoryActivities,
|
|
16
|
+
)
|
|
17
|
+
from xmemory_temporal.config import XmemoryConfig, XmemoryTimeouts
|
|
18
|
+
from xmemory_temporal.dto import (
|
|
19
|
+
ReadInput,
|
|
20
|
+
ReadOutput,
|
|
21
|
+
ReadScope,
|
|
22
|
+
ScopeObject,
|
|
23
|
+
SubAnswer,
|
|
24
|
+
WriteInput,
|
|
25
|
+
WriteOutput,
|
|
26
|
+
WriteStartOutput,
|
|
27
|
+
WriteStatusInput,
|
|
28
|
+
WriteStatusOutput,
|
|
29
|
+
)
|
|
30
|
+
from xmemory_temporal.errors import NON_RETRYABLE_TYPES, to_application_error
|
|
31
|
+
from xmemory_temporal.interceptor import AutoCaptureConfig
|
|
32
|
+
from xmemory_temporal.plugin import XmemoryPlugin
|
|
33
|
+
from xmemory_temporal.protocol import XmemoryInstanceProtocol
|
|
34
|
+
from xmemory_temporal.workflow_api import WorkflowXmemory, xmemory_for_workflow
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"XmemoryPlugin",
|
|
38
|
+
"XmemoryConfig",
|
|
39
|
+
"XmemoryTimeouts",
|
|
40
|
+
"AutoCaptureConfig",
|
|
41
|
+
"WorkflowXmemory",
|
|
42
|
+
"xmemory_for_workflow",
|
|
43
|
+
"XmemoryActivities",
|
|
44
|
+
"XmemoryInstanceProtocol",
|
|
45
|
+
"ReadInput",
|
|
46
|
+
"ReadOutput",
|
|
47
|
+
"ReadScope",
|
|
48
|
+
"ScopeObject",
|
|
49
|
+
"SubAnswer",
|
|
50
|
+
"WriteInput",
|
|
51
|
+
"WriteOutput",
|
|
52
|
+
"WriteStartOutput",
|
|
53
|
+
"WriteStatusInput",
|
|
54
|
+
"WriteStatusOutput",
|
|
55
|
+
"NON_RETRYABLE_TYPES",
|
|
56
|
+
"to_application_error",
|
|
57
|
+
"ACTIVITY_READ",
|
|
58
|
+
"ACTIVITY_WRITE",
|
|
59
|
+
"ACTIVITY_WRITE_START",
|
|
60
|
+
"ACTIVITY_WRITE_STATUS",
|
|
61
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The xmemory activities: the only place this package does I/O.
|
|
2
|
+
|
|
3
|
+
The client is injected per Worker; each call's client timeout is derived from
|
|
4
|
+
the deadline Temporal assigned the activity. ``xmemory-ai`` is natively async,
|
|
5
|
+
so these are plain ``async def`` with no thread pool.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextvars
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from temporalio import activity
|
|
12
|
+
from temporalio.exceptions import ApplicationError
|
|
13
|
+
|
|
14
|
+
from xmemory_temporal.config import XmemoryConfig, client_timeout_seconds
|
|
15
|
+
from xmemory_temporal.dto import (
|
|
16
|
+
ReadInput,
|
|
17
|
+
ReadOutput,
|
|
18
|
+
WriteInput,
|
|
19
|
+
WriteOutput,
|
|
20
|
+
WriteStartOutput,
|
|
21
|
+
WriteStatusInput,
|
|
22
|
+
WriteStatusOutput,
|
|
23
|
+
project_read,
|
|
24
|
+
project_write,
|
|
25
|
+
project_write_start,
|
|
26
|
+
project_write_status,
|
|
27
|
+
)
|
|
28
|
+
from xmemory_temporal.errors import TYPE_NO_DEADLINE, TYPE_NOT_BOUND, to_application_error
|
|
29
|
+
from xmemory_temporal.protocol import XmemoryInstanceProtocol
|
|
30
|
+
|
|
31
|
+
# Pinned so renaming a method cannot break replay of in-flight workflows.
|
|
32
|
+
ACTIVITY_READ = "xmemory_read"
|
|
33
|
+
ACTIVITY_WRITE = "xmemory_write"
|
|
34
|
+
ACTIVITY_WRITE_START = "xmemory_write_start"
|
|
35
|
+
ACTIVITY_WRITE_STATUS = "xmemory_write_status"
|
|
36
|
+
|
|
37
|
+
# A ContextVar, not an attribute: one plugin object is shared across every Worker
|
|
38
|
+
# built from a Client, so an attribute would be last-bind-wins and a Worker could
|
|
39
|
+
# reach another's closed client. Each Worker's run_context binds its own.
|
|
40
|
+
_bound_instance: contextvars.ContextVar[XmemoryInstanceProtocol | None] = contextvars.ContextVar(
|
|
41
|
+
"xmemory_bound_instance", default=None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class XmemoryActivities:
|
|
46
|
+
"""The xmemory activity functions; the client is bound per-Worker (contextvar)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: XmemoryConfig) -> None:
|
|
49
|
+
self._config = config
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def bind(instance: XmemoryInstanceProtocol) -> contextvars.Token:
|
|
53
|
+
"""Bind the live client for the current context. Returns a reset token."""
|
|
54
|
+
return _bound_instance.set(instance)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def unbind(token: contextvars.Token) -> None:
|
|
58
|
+
_bound_instance.reset(token)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def instance(self) -> XmemoryInstanceProtocol:
|
|
62
|
+
inst = _bound_instance.get()
|
|
63
|
+
if inst is None:
|
|
64
|
+
# Activities registered without the plugin. Non-retryable: no retry
|
|
65
|
+
# can bind a client.
|
|
66
|
+
raise ApplicationError(
|
|
67
|
+
"xmemory activities are not bound to a client — register XmemoryPlugin on the "
|
|
68
|
+
"Client (the Worker inherits it) rather than registering the activity functions "
|
|
69
|
+
"directly.",
|
|
70
|
+
type=TYPE_NOT_BOUND,
|
|
71
|
+
non_retryable=True,
|
|
72
|
+
)
|
|
73
|
+
return inst
|
|
74
|
+
|
|
75
|
+
def _client_timeout(self) -> float:
|
|
76
|
+
"""Client budget for this call, derived from the activity's own deadline.
|
|
77
|
+
|
|
78
|
+
Deriving it, rather than keeping a second worker-side copy, is what
|
|
79
|
+
makes "the client gives up first" hold by construction when a workflow
|
|
80
|
+
lowers its timeout.
|
|
81
|
+
|
|
82
|
+
Temporal requires one of the two close timeouts on every activity, so
|
|
83
|
+
the ``None`` branch is unreachable in practice; both fields are typed
|
|
84
|
+
optional, and a silent default there is exactly the second copy this
|
|
85
|
+
design exists to avoid.
|
|
86
|
+
"""
|
|
87
|
+
info = activity.info()
|
|
88
|
+
budget = info.start_to_close_timeout or info.schedule_to_close_timeout
|
|
89
|
+
if budget is None:
|
|
90
|
+
raise ApplicationError(
|
|
91
|
+
f"activity {info.activity_type} was scheduled without a deadline: set "
|
|
92
|
+
"start_to_close_timeout or schedule_to_close_timeout on it.",
|
|
93
|
+
type=TYPE_NO_DEADLINE,
|
|
94
|
+
non_retryable=True,
|
|
95
|
+
)
|
|
96
|
+
return client_timeout_seconds(budget.total_seconds(), self._config.client_margin_seconds)
|
|
97
|
+
|
|
98
|
+
@activity.defn(name=ACTIVITY_READ)
|
|
99
|
+
async def read(self, request: ReadInput) -> ReadOutput:
|
|
100
|
+
# Outside the try: an unbound-client or missing-deadline error must keep
|
|
101
|
+
# its non-retryable ApplicationError rather than being re-mapped.
|
|
102
|
+
instance = self.instance
|
|
103
|
+
timeout = self._client_timeout()
|
|
104
|
+
kwargs: dict[str, Any] = {}
|
|
105
|
+
if request.read_mode is not None:
|
|
106
|
+
kwargs["read_mode"] = request.read_mode
|
|
107
|
+
if request.scope is not None:
|
|
108
|
+
# The client validates this into its own ReadScope model; hand it a
|
|
109
|
+
# plain mapping so we never import a vendor type into the DTO layer.
|
|
110
|
+
kwargs["scope"] = {
|
|
111
|
+
"objects": [{"type": o.type, "key": o.key} for o in request.scope.objects],
|
|
112
|
+
"relations_scope": request.scope.relations_scope,
|
|
113
|
+
}
|
|
114
|
+
if request.read_id is not None:
|
|
115
|
+
kwargs["read_id"] = request.read_id
|
|
116
|
+
try:
|
|
117
|
+
result = await instance.read(
|
|
118
|
+
request.query,
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
**kwargs,
|
|
121
|
+
)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
# `from None`, not `from exc`: Temporal serializes the cause chain
|
|
124
|
+
# into cleartext history and the client's message is unsanitized.
|
|
125
|
+
# Only `from None` suppresses the implicit __context__ too.
|
|
126
|
+
raise to_application_error(exc) from None
|
|
127
|
+
return project_read(result)
|
|
128
|
+
|
|
129
|
+
@activity.defn(name=ACTIVITY_WRITE)
|
|
130
|
+
async def write(self, request: WriteInput) -> WriteOutput:
|
|
131
|
+
instance = self.instance
|
|
132
|
+
timeout = self._client_timeout()
|
|
133
|
+
try:
|
|
134
|
+
result = await instance.write(
|
|
135
|
+
request.text,
|
|
136
|
+
timeout=timeout,
|
|
137
|
+
**self._write_kwargs(request),
|
|
138
|
+
)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
raise to_application_error(exc) from None
|
|
141
|
+
return project_write(result)
|
|
142
|
+
|
|
143
|
+
@activity.defn(name=ACTIVITY_WRITE_START)
|
|
144
|
+
async def write_start(self, request: WriteInput) -> WriteStartOutput:
|
|
145
|
+
instance = self.instance
|
|
146
|
+
timeout = self._client_timeout()
|
|
147
|
+
try:
|
|
148
|
+
result = await instance.write_async(
|
|
149
|
+
request.text,
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
**self._write_kwargs(request),
|
|
152
|
+
)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
raise to_application_error(exc) from None
|
|
155
|
+
return project_write_start(result)
|
|
156
|
+
|
|
157
|
+
@activity.defn(name=ACTIVITY_WRITE_STATUS)
|
|
158
|
+
async def write_status(self, request: WriteStatusInput) -> WriteStatusOutput:
|
|
159
|
+
instance = self.instance
|
|
160
|
+
timeout = self._client_timeout()
|
|
161
|
+
try:
|
|
162
|
+
result = await instance.write_status(
|
|
163
|
+
request.write_id,
|
|
164
|
+
timeout=timeout,
|
|
165
|
+
)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
raise to_application_error(exc) from None
|
|
168
|
+
return project_write_status(result)
|
|
169
|
+
|
|
170
|
+
def _write_kwargs(self, request: WriteInput) -> dict[str, Any]:
|
|
171
|
+
kwargs: dict[str, Any] = {}
|
|
172
|
+
if request.structured_mutations is not None:
|
|
173
|
+
# A structured write carries its own keys, so the server applies it
|
|
174
|
+
# without running the extractor; text and extraction_logic are moot.
|
|
175
|
+
kwargs["structured_mutations"] = request.structured_mutations
|
|
176
|
+
return kwargs
|
|
177
|
+
logic = request.extraction_logic or self._config.default_extraction_logic
|
|
178
|
+
if logic is not None:
|
|
179
|
+
kwargs["extraction_logic"] = logic
|
|
180
|
+
if request.diff_engine is not None:
|
|
181
|
+
kwargs["diff_engine"] = request.diff_engine
|
|
182
|
+
return kwargs
|
|
183
|
+
|
|
184
|
+
def as_sequence(self) -> list[Callable[..., Any]]:
|
|
185
|
+
"""The bound methods to hand to ``SimplePlugin(activities=...)``."""
|
|
186
|
+
return [self.read, self.write, self.write_start, self.write_status]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""One xmemory client per worker, shared by all concurrent activities.
|
|
2
|
+
|
|
3
|
+
Per-invocation clients would mean a TCP+TLS handshake per memory op.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from xmemory_temporal.config import XmemoryConfig, XmemoryTimeouts, client_timeout_seconds
|
|
13
|
+
from xmemory_temporal.protocol import XmemoryInstanceProtocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def open_instance(
|
|
18
|
+
config: XmemoryConfig,
|
|
19
|
+
*,
|
|
20
|
+
api_key: str | None = None,
|
|
21
|
+
http_client: httpx.AsyncClient | None = None,
|
|
22
|
+
) -> AsyncGenerator[XmemoryInstanceProtocol, None]:
|
|
23
|
+
"""Yield a bound instance handle, closing the client on exit.
|
|
24
|
+
|
|
25
|
+
``api_key`` overrides the environment lookup; ``http_client`` supplies a
|
|
26
|
+
caller-owned transport (which the xmemory client will not close).
|
|
27
|
+
"""
|
|
28
|
+
# Lazy import: injecting a fake must not require xmemory-ai at all.
|
|
29
|
+
from xmemory import AsyncXmemoryClient
|
|
30
|
+
|
|
31
|
+
key = api_key or config.resolve_api_key()
|
|
32
|
+
# Fallback only; every activity overrides this per call (activities.py).
|
|
33
|
+
default_timeout = client_timeout_seconds(XmemoryTimeouts().read_seconds, config.client_margin_seconds)
|
|
34
|
+
kwargs: dict[str, Any] = {"api_key": key, "timeout": default_timeout}
|
|
35
|
+
if http_client is not None:
|
|
36
|
+
# `url` + `http_client` together is rejected; the caller sets base_url.
|
|
37
|
+
kwargs["http_client"] = http_client
|
|
38
|
+
elif config.url is not None:
|
|
39
|
+
kwargs["url"] = config.url
|
|
40
|
+
|
|
41
|
+
# Closes only a client-owned transport, so a caller's stays open.
|
|
42
|
+
async with AsyncXmemoryClient(**kwargs) as client:
|
|
43
|
+
yield client.instance(config.instance_id)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Worker-side configuration.
|
|
2
|
+
|
|
3
|
+
Carries the *name* of the env var holding the API key, never the key, so the
|
|
4
|
+
config is safe to log, serialize, and persist into Temporal history.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_KEY_ENV = "XMEM_API_KEY"
|
|
13
|
+
DEFAULT_CLIENT_MARGIN_SECONDS = 5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def client_timeout_seconds(
|
|
17
|
+
activity_seconds: float,
|
|
18
|
+
margin_seconds: int = DEFAULT_CLIENT_MARGIN_SECONDS,
|
|
19
|
+
) -> float:
|
|
20
|
+
"""Client budget for an activity whose Temporal deadline is ``activity_seconds``.
|
|
21
|
+
|
|
22
|
+
Always strictly below that deadline, so the client fails first with an
|
|
23
|
+
attributable xmemory error. Budgets at or under the margin get a
|
|
24
|
+
proportional one, so the ordering holds for every positive budget.
|
|
25
|
+
"""
|
|
26
|
+
if activity_seconds <= margin_seconds:
|
|
27
|
+
return max(0.1, activity_seconds * 0.8)
|
|
28
|
+
return float(activity_seconds - margin_seconds)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class XmemoryTimeouts(BaseModel):
|
|
32
|
+
"""Default ``start_to_close`` budgets applied by ``xmemory_for_workflow()``.
|
|
33
|
+
|
|
34
|
+
The workflow owns the real budget; activities derive their client timeout
|
|
35
|
+
from whatever Temporal assigned.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
read_seconds: int = 120
|
|
41
|
+
write_seconds: int = 180
|
|
42
|
+
write_start_seconds: int = 30
|
|
43
|
+
write_status_seconds: int = 30
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def read(self) -> timedelta:
|
|
47
|
+
return timedelta(seconds=self.read_seconds)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def write(self) -> timedelta:
|
|
51
|
+
return timedelta(seconds=self.write_seconds)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def write_start(self) -> timedelta:
|
|
55
|
+
return timedelta(seconds=self.write_start_seconds)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def write_status(self) -> timedelta:
|
|
59
|
+
return timedelta(seconds=self.write_status_seconds)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class XmemoryConfig(BaseModel):
|
|
63
|
+
"""Worker-side configuration for the xmemory plugin.
|
|
64
|
+
|
|
65
|
+
No credential: the key is read from ``os.environ[api_key_env]``, or passed
|
|
66
|
+
in-process via ``XmemoryPlugin(config, api_key=...)``. Activity budgets are
|
|
67
|
+
not here either; they belong to ``xmemory_for_workflow``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(frozen=True)
|
|
71
|
+
|
|
72
|
+
instance_id: str
|
|
73
|
+
url: str | None = None
|
|
74
|
+
api_key_env: str = DEFAULT_API_KEY_ENV
|
|
75
|
+
# Gap between a call's Temporal deadline and its client timeout. If inverted,
|
|
76
|
+
# Temporal could abandon a write_start whose POST still enqueues server-side,
|
|
77
|
+
# and a later durable-write retry would double-enqueue.
|
|
78
|
+
client_margin_seconds: int = DEFAULT_CLIENT_MARGIN_SECONDS
|
|
79
|
+
default_extraction_logic: str = "fast"
|
|
80
|
+
# Summaries are visible to anyone with namespace access, and memory text is
|
|
81
|
+
# often personal, so content is redacted unless a caller opts in.
|
|
82
|
+
include_content_in_summary: bool = False
|
|
83
|
+
|
|
84
|
+
def resolve_api_key(self) -> str:
|
|
85
|
+
"""Read the API key from the environment.
|
|
86
|
+
|
|
87
|
+
Raises at worker start rather than on the first activity, so a
|
|
88
|
+
misconfigured worker fails visibly.
|
|
89
|
+
"""
|
|
90
|
+
key = os.environ.get(self.api_key_env)
|
|
91
|
+
if not key:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"xmemory API key not found: environment variable {self.api_key_env!r} is unset or empty. "
|
|
94
|
+
f"Set it on the worker process, or pass XmemoryPlugin(config, api_key=...)."
|
|
95
|
+
)
|
|
96
|
+
return key
|
xmemory_temporal/dto.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Activity input/output types: ours, not the client's.
|
|
2
|
+
|
|
3
|
+
Activity payloads are persisted verbatim into workflow history, so the type that
|
|
4
|
+
crosses that boundary becomes a compatibility contract for every workflow that
|
|
5
|
+
has ever run. Owning the wire format lets ``xmemory-ai`` evolve underneath us
|
|
6
|
+
without breaking replay of completed workflows.
|
|
7
|
+
|
|
8
|
+
Dataclasses, not pydantic: Temporal's default converter reconstructs those with
|
|
9
|
+
no configuration, whereas pydantic models need a namespace-wide converter this
|
|
10
|
+
plugin refuses to impose. For the same reason this module uses real annotations
|
|
11
|
+
(no ``from __future__ import annotations``), since the converter resolves them via
|
|
12
|
+
``typing.get_type_hints``, which fails on stringized ones.
|
|
13
|
+
|
|
14
|
+
Outputs are flattened projections — only the fields a workflow can act on.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ScopeObject:
|
|
23
|
+
"""One record a scoped read may touch, addressed by its primary key."""
|
|
24
|
+
|
|
25
|
+
type: str
|
|
26
|
+
key: dict[str, str | int | float | bool] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ReadScope:
|
|
31
|
+
"""Restrict a read to specific records.
|
|
32
|
+
|
|
33
|
+
Mirrors the client's ``ReadScope`` so a malformed scope is a type error at
|
|
34
|
+
author time rather than a non-retryable ``XmemoryBadRequest`` at runtime.
|
|
35
|
+
``relations_scope`` is ``no_relations`` (objects only) by default;
|
|
36
|
+
``all_relations`` also exposes relations among the in-scope objects.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
objects: list[ScopeObject] = field(default_factory=list)
|
|
40
|
+
relations_scope: str = "no_relations"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ReadInput:
|
|
45
|
+
query: str
|
|
46
|
+
read_mode: str | None = None
|
|
47
|
+
scope: ReadScope | None = None
|
|
48
|
+
read_id: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SubAnswer:
|
|
53
|
+
"""One decomposed sub-query and its own answer."""
|
|
54
|
+
|
|
55
|
+
sub_query: str
|
|
56
|
+
reader_result: Any = None
|
|
57
|
+
error: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ReadOutput:
|
|
62
|
+
reader_result: Any = None
|
|
63
|
+
sub_answers: list[SubAnswer] = field(default_factory=list)
|
|
64
|
+
trace_id: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class WriteInput:
|
|
69
|
+
"""Either free ``text`` for the extractor, or explicit ``structured_mutations``.
|
|
70
|
+
|
|
71
|
+
Mutations are plain JSON dicts in the client's ``WriteMutation`` shape. They
|
|
72
|
+
are kept as dicts rather than the client's pydantic models because activity
|
|
73
|
+
payloads must round-trip through Temporal's default converter.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
text: str = ""
|
|
77
|
+
extraction_logic: str | None = None
|
|
78
|
+
diff_engine: bool | None = None
|
|
79
|
+
structured_mutations: list[dict[str, Any]] | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class WriteOutput:
|
|
84
|
+
write_id: str
|
|
85
|
+
trace_id: str | None = None
|
|
86
|
+
changes: Any = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class WriteStartOutput:
|
|
91
|
+
write_id: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class WriteStatusInput:
|
|
96
|
+
write_id: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class WriteStatusOutput:
|
|
101
|
+
write_id: str
|
|
102
|
+
write_status: str
|
|
103
|
+
error_detail: str | None = None
|
|
104
|
+
completed_at: str | None = None
|
|
105
|
+
# What the write applied. None until xmemory-ai surfaces it on write_status
|
|
106
|
+
# (see project_write_status); kept for symmetry with WriteOutput.changes.
|
|
107
|
+
changes: Any = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- Projections from the client's models ----------------------------------
|
|
111
|
+
# `getattr` with defaults rather than attribute access: an older or newer client
|
|
112
|
+
# release may not carry every field, and a missing one should degrade to `None`
|
|
113
|
+
# rather than raise inside an activity.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def project_read(result: Any) -> ReadOutput:
|
|
117
|
+
raw_sub = getattr(result, "reader_results", None) or []
|
|
118
|
+
return ReadOutput(
|
|
119
|
+
reader_result=getattr(result, "reader_result", None),
|
|
120
|
+
sub_answers=[
|
|
121
|
+
SubAnswer(
|
|
122
|
+
sub_query=getattr(item, "sub_query", ""),
|
|
123
|
+
reader_result=getattr(item, "reader_result", None),
|
|
124
|
+
error=getattr(item, "error", None),
|
|
125
|
+
)
|
|
126
|
+
for item in raw_sub
|
|
127
|
+
],
|
|
128
|
+
trace_id=getattr(result, "trace_id", None),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def project_write(result: Any) -> WriteOutput:
|
|
133
|
+
return WriteOutput(
|
|
134
|
+
write_id=getattr(result, "write_id", ""),
|
|
135
|
+
trace_id=getattr(result, "trace_id", None),
|
|
136
|
+
changes=getattr(result, "changes", None),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def project_write_start(result: Any) -> WriteStartOutput:
|
|
141
|
+
return WriteStartOutput(write_id=getattr(result, "write_id", ""))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def project_write_status(result: Any) -> WriteStatusOutput:
|
|
145
|
+
status = getattr(result, "write_status", None)
|
|
146
|
+
completed_at = getattr(result, "completed_at", None)
|
|
147
|
+
return WriteStatusOutput(
|
|
148
|
+
write_id=getattr(result, "write_id", ""),
|
|
149
|
+
# `WriteQueueStatus` is a `str` enum; normalize to its plain value so
|
|
150
|
+
# history never embeds an enum class the workflow side must import.
|
|
151
|
+
write_status=getattr(status, "value", status) or "",
|
|
152
|
+
error_detail=getattr(result, "error_detail", None),
|
|
153
|
+
completed_at=completed_at.isoformat() if completed_at is not None else None,
|
|
154
|
+
# The server returns what the write applied, but xmemory-ai's
|
|
155
|
+
# WriteStatusResult does not surface it yet — so `changes` is None here
|
|
156
|
+
# (unlike sync `write`, which carries WriteResult.changes). Picked up via
|
|
157
|
+
# getattr so it auto-populates if a future client exposes it. Surfacing it
|
|
158
|
+
# is an upstream client follow-up.
|
|
159
|
+
changes=getattr(result, "changes", None) or getattr(result, "result", None),
|
|
160
|
+
)
|