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.
@@ -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
@@ -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
+ )