openai-agents-memorysync 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,45 @@
1
+ """openai-agents-memorysync — MemorySync memory for the OpenAI Agents SDK.
2
+
3
+ Three layers, all optional and composable:
4
+
5
+ - ``MemorySyncSession`` — a drop-in implementation of the SDK's Session
6
+ protocol: durable server-side conversation history that survives
7
+ restarts and deploys, shared across multi-agent handoffs, with
8
+ automatic long-term memory extraction.
9
+ - ``memory_instructions`` — dynamic instructions that inject recalled
10
+ memory context per run.
11
+ - ``create_memory_tools`` — five agentic memory tools (add, search,
12
+ list, update, delete) that never raise.
13
+ - Helpers — ``get_memory_context``, ``search_memories``, ``save_turn``
14
+ for hand-wired setups.
15
+ """
16
+
17
+ from ._api import (
18
+ FALLBACK_TENANT,
19
+ MemorySyncAPIError,
20
+ fnv1a64,
21
+ resolve_api_key,
22
+ resolve_base_url,
23
+ )
24
+ from ._version import __version__
25
+ from .helpers import get_memory_context, save_turn, search_memories
26
+ from .instructions import DEFAULT_TEMPLATE, PROFILE_PROMPT, memory_instructions
27
+ from .session import MemorySyncSession
28
+ from .tools import create_memory_tools
29
+
30
+ __all__ = [
31
+ "MemorySyncSession",
32
+ "memory_instructions",
33
+ "create_memory_tools",
34
+ "get_memory_context",
35
+ "search_memories",
36
+ "save_turn",
37
+ "MemorySyncAPIError",
38
+ "DEFAULT_TEMPLATE",
39
+ "PROFILE_PROMPT",
40
+ "FALLBACK_TENANT",
41
+ "fnv1a64",
42
+ "resolve_api_key",
43
+ "resolve_base_url",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,305 @@
1
+ """Async client for the MemorySync v1 data plane used by this integration.
2
+
3
+ The session store persists items through the *episodic* ingestion path
4
+ (``POST /v1/memory/add_turn``), which stores text verbatim — no fact
5
+ extraction, no low-value-chatter gate, no rewriting. A conversation
6
+ transcript that the Agents SDK will replay to the model must round-trip
7
+ byte-for-byte; a plane that second-guessed it would corrupt the run.
8
+
9
+ Everything here is async-native because the Agents SDK's ``Session``
10
+ protocol is async and its runner drives every call from the event loop —
11
+ a blocking HTTP client inside ``get_items`` would stall the whole run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ import httpx
20
+
21
+ from ._version import __version__
22
+
23
+ DEFAULT_BASE_URL = "https://api.memorysync.io"
24
+ _USER_AGENT = f"openai-agents-memorysync/{__version__}"
25
+
26
+ #: Namespace used when the key cannot list projects (see resolve_tenant_id).
27
+ FALLBACK_TENANT = "default"
28
+
29
+
30
+ class MemorySyncAPIError(Exception):
31
+ """A MemorySync call failed. Carries the status code and server detail."""
32
+
33
+ def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+
37
+
38
+ def resolve_api_key(api_key: Optional[str]) -> str:
39
+ key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
40
+ if not key or not key.strip():
41
+ raise ValueError(
42
+ "A MemorySync API key is required. Pass api_key=... or set the "
43
+ "MEMORYSYNC_API_KEY environment variable."
44
+ )
45
+ return key.strip()
46
+
47
+
48
+ def resolve_base_url(base_url: Optional[str]) -> str:
49
+ url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
50
+ return url.rstrip("/")
51
+
52
+
53
+ def fnv1a64(value: str) -> str:
54
+ """FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
55
+
56
+ Over UTF-16 code units — not code points, not UTF-8 bytes — so the
57
+ output matches the JavaScript adapters (`fnv1a64` in memorysync-ai-sdk
58
+ and memorysync-mastra hash over ``charCodeAt``) character for
59
+ character. Identical seeds across languages mean a turn persisted by a
60
+ Python surface and again by a JS surface converge on one stored row.
61
+ """
62
+ prime = 0x100000001B3
63
+ mask = 0xFFFFFFFFFFFFFFFF
64
+ h = 0xCBF29CE484222325
65
+ data = value.encode("utf-16-le")
66
+ for i in range(0, len(data), 2):
67
+ unit = data[i] | (data[i + 1] << 8)
68
+ h ^= unit
69
+ h = (h * prime) & mask
70
+ return format(h, "016x")
71
+
72
+
73
+ class AsyncV1Api:
74
+ """Minimal asynchronous v1 client: add_turn, recall, query, list."""
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ api_key: str,
80
+ base_url: str,
81
+ project_id: Optional[str] = None,
82
+ timeout: float = 30.0,
83
+ transport: Optional[httpx.AsyncBaseTransport] = None,
84
+ ) -> None:
85
+ self._api_key = api_key
86
+ self._base_url = base_url.rstrip("/")
87
+ self._project_id = project_id
88
+ self._timeout = timeout
89
+ self._transport = transport
90
+ self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
91
+ self._tenant_id: Optional[str] = None
92
+ self._tenant_is_fallback = False
93
+
94
+ async def aclose(self) -> None:
95
+ await self._http.aclose()
96
+
97
+ @property
98
+ def api_key(self) -> str:
99
+ return self._api_key
100
+
101
+ @property
102
+ def base_url(self) -> str:
103
+ return self._base_url
104
+
105
+ @property
106
+ def project_id(self) -> Optional[str]:
107
+ return self._project_id
108
+
109
+ @property
110
+ def timeout(self) -> float:
111
+ return self._timeout
112
+
113
+ @property
114
+ def transport(self) -> Optional[httpx.AsyncBaseTransport]:
115
+ return self._transport
116
+
117
+ # ── plumbing ─────────────────────────────────────────────────────
118
+
119
+ def _headers(self) -> Dict[str, str]:
120
+ h = {
121
+ "X-API-Key": self._api_key,
122
+ "Accept": "application/json",
123
+ "User-Agent": _USER_AGENT,
124
+ }
125
+ if self._project_id:
126
+ h["X-Project-ID"] = self._project_id
127
+ return h
128
+
129
+ async def _request(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ *,
134
+ json: Optional[Dict[str, Any]] = None,
135
+ params: Optional[Dict[str, Any]] = None,
136
+ ) -> Any:
137
+ url = f"{self._base_url}{path}"
138
+ try:
139
+ response = await self._http.request(
140
+ method, url, headers=self._headers(), json=json, params=params
141
+ )
142
+ except httpx.TimeoutException as e:
143
+ raise MemorySyncAPIError(f"Request timed out: {e}") from e
144
+ except httpx.HTTPError as e:
145
+ raise MemorySyncAPIError(f"Network error: {e}") from e
146
+
147
+ if response.status_code == 204:
148
+ return None
149
+ try:
150
+ body: Any = response.json()
151
+ except ValueError:
152
+ body = response.text or None
153
+ if response.status_code >= 400:
154
+ detail = body.get("detail") if isinstance(body, dict) else body
155
+ raise MemorySyncAPIError(
156
+ f"{method} {path} failed with HTTP {response.status_code}: {detail}",
157
+ status_code=response.status_code,
158
+ )
159
+ return body
160
+
161
+ # ── calls ────────────────────────────────────────────────────────
162
+
163
+ async def resolve_tenant_id(self) -> str:
164
+ """The tenant id, which the v1 routes need in path or body.
165
+
166
+ Derived from the project listing rather than asked for. Cached for
167
+ the lifetime of this client; one extra GET per process, not per
168
+ turn. Keys without the ``projects:read`` scope (evaluation keys)
169
+ fall back to the fixed namespace ``"default"`` — deterministic, so
170
+ every read and write through this client lands in one namespace.
171
+ Only a definite 401/403 triggers the fallback; a transient server
172
+ error re-raises rather than silently switching namespaces.
173
+ """
174
+ if self._tenant_id:
175
+ return self._tenant_id
176
+ try:
177
+ projects = await self._request("GET", "/org/projects")
178
+ except MemorySyncAPIError as exc:
179
+ if exc.status_code in (401, 403):
180
+ self._tenant_id = FALLBACK_TENANT
181
+ self._tenant_is_fallback = True
182
+ return self._tenant_id
183
+ raise
184
+ first = projects[0] if isinstance(projects, list) and projects else None
185
+ tenant = first.get("tenant_id") if isinstance(first, dict) else None
186
+ if not tenant:
187
+ raise MemorySyncAPIError(
188
+ "Could not determine the tenant for this API key. Pass "
189
+ "tenant_id=... explicitly, or verify the key with "
190
+ "`memorysync doctor`."
191
+ )
192
+ self._tenant_id = str(tenant)
193
+ return self._tenant_id
194
+
195
+ @property
196
+ def tenant_is_fallback(self) -> bool:
197
+ """True when the namespace came from the 401/403 fallback.
198
+
199
+ Callers that mix this v1 plane with the header-scoped legacy plane
200
+ (the agent tools do) check this: under the fallback the two planes
201
+ resolve *different* internal users, so a v1 read would silently
202
+ miss legacy-plane writes. Better to say so than to answer wrongly.
203
+ """
204
+ return self._tenant_is_fallback
205
+
206
+ def set_tenant_id(self, tenant_id: str) -> None:
207
+ self._tenant_id = tenant_id
208
+
209
+ async def add_turn(
210
+ self,
211
+ *,
212
+ tenant_id: str,
213
+ user_id: str,
214
+ text: str,
215
+ speaker: Optional[str] = None,
216
+ occurred_at: Optional[str] = None,
217
+ metadata: Optional[Dict[str, Any]] = None,
218
+ source: str = "openai-agents",
219
+ sync_embed: bool = False,
220
+ ) -> Dict[str, Any]:
221
+ """Store one item verbatim (episodic ingestion).
222
+
223
+ ``speaker`` + ``occurred_at`` participate in the server's
224
+ idempotency seed, so retrying an identical payload is recognised
225
+ (``already_exists: true``) instead of stored twice.
226
+ """
227
+ body: Dict[str, Any] = {
228
+ "tenant_id": tenant_id,
229
+ "user_id": user_id,
230
+ "source": source,
231
+ "text": text,
232
+ "sync_embed": sync_embed,
233
+ }
234
+ if speaker is not None:
235
+ body["speaker"] = speaker
236
+ if occurred_at is not None:
237
+ body["occurred_at"] = occurred_at
238
+ if metadata is not None:
239
+ body["metadata"] = metadata
240
+ return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
241
+
242
+ async def recall(
243
+ self,
244
+ *,
245
+ tenant_id: str,
246
+ user_id: str,
247
+ prompt: str,
248
+ k: Optional[int] = None,
249
+ types: Optional[List[str]] = None,
250
+ ) -> Dict[str, Any]:
251
+ """Hierarchical recall: grouped, prompt-ready context block."""
252
+ body: Dict[str, Any] = {
253
+ "tenant_id": tenant_id,
254
+ "user_id": user_id,
255
+ "prompt": prompt,
256
+ }
257
+ if k is not None:
258
+ body["k"] = k
259
+ if types is not None:
260
+ body["types"] = types
261
+ return await self._request("POST", "/v1/memory/recall", json=body) or {}
262
+
263
+ async def query(
264
+ self,
265
+ *,
266
+ tenant_id: str,
267
+ user_id: str,
268
+ prompt: str,
269
+ k: Optional[int] = None,
270
+ ) -> Dict[str, Any]:
271
+ """Plain semantic search over the pair's memories (episodic included)."""
272
+ body: Dict[str, Any] = {
273
+ "tenant_id": tenant_id,
274
+ "user_id": user_id,
275
+ "prompt": prompt,
276
+ }
277
+ if k is not None:
278
+ body["k"] = k
279
+ return await self._request("POST", "/v1/memory/query", json=body) or {}
280
+
281
+ async def list_memories(
282
+ self,
283
+ *,
284
+ tenant_id: str,
285
+ user_id: str,
286
+ limit: int = 0,
287
+ ) -> List[Dict[str, Any]]:
288
+ """Every memory for the tenant/user pair, newest first.
289
+
290
+ ``limit=0`` means no limit — a transcript read must never be
291
+ silently truncated, so that is the default here.
292
+
293
+ Both path segments are percent-encoded so an id containing ``/``,
294
+ ``%`` or ``?`` addresses the right row set — the server decodes
295
+ path parameters, so sending the raw string would corrupt the path.
296
+ """
297
+ from urllib.parse import quote
298
+
299
+ raw = await self._request(
300
+ "GET",
301
+ f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
302
+ params={"limit": limit},
303
+ )
304
+ memories = raw.get("memories") if isinstance(raw, dict) else None
305
+ return list(memories) if isinstance(memories, list) else []
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,134 @@
1
+ """Standalone async helpers for hand-wired setups.
2
+
3
+ Same wire contracts as the session and instructions surfaces — same
4
+ recall pipeline, same idempotent persistence seeds — so mixing styles
5
+ never double-stores a turn.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import httpx
13
+
14
+ from ._api import AsyncV1Api, fnv1a64, resolve_api_key, resolve_base_url
15
+
16
+
17
+ def _api(
18
+ api_key: Optional[str],
19
+ base_url: Optional[str],
20
+ project_id: Optional[str],
21
+ tenant_id: Optional[str],
22
+ timeout: float,
23
+ transport: Optional[httpx.AsyncBaseTransport],
24
+ ) -> AsyncV1Api:
25
+ api = AsyncV1Api(
26
+ api_key=resolve_api_key(api_key),
27
+ base_url=resolve_base_url(base_url),
28
+ project_id=project_id,
29
+ timeout=timeout,
30
+ transport=transport,
31
+ )
32
+ if tenant_id:
33
+ api.set_tenant_id(str(tenant_id))
34
+ return api
35
+
36
+
37
+ async def get_memory_context(
38
+ prompt: str,
39
+ *,
40
+ user_id: str,
41
+ k: Optional[int] = None,
42
+ api_key: Optional[str] = None,
43
+ base_url: Optional[str] = None,
44
+ project_id: Optional[str] = None,
45
+ tenant_id: Optional[str] = None,
46
+ timeout: float = 30.0,
47
+ transport: Optional[httpx.AsyncBaseTransport] = None,
48
+ ) -> str:
49
+ """A prompt-ready context block for ``prompt``, or ``""`` when nothing
50
+ matches (a new user is not an error)."""
51
+ api = _api(api_key, base_url, project_id, tenant_id, timeout, transport)
52
+ try:
53
+ tenant = await api.resolve_tenant_id()
54
+ raw = await api.recall(tenant_id=tenant, user_id=user_id, prompt=prompt, k=k)
55
+ return str(raw.get("context") or "")
56
+ finally:
57
+ await api.aclose()
58
+
59
+
60
+ async def search_memories(
61
+ query: str,
62
+ *,
63
+ user_id: str,
64
+ k: Optional[int] = None,
65
+ api_key: Optional[str] = None,
66
+ base_url: Optional[str] = None,
67
+ project_id: Optional[str] = None,
68
+ tenant_id: Optional[str] = None,
69
+ timeout: float = 30.0,
70
+ transport: Optional[httpx.AsyncBaseTransport] = None,
71
+ ) -> List[Dict[str, Any]]:
72
+ """Scored memories relevant to ``query`` as ``{id, text, score}`` dicts."""
73
+ api = _api(api_key, base_url, project_id, tenant_id, timeout, transport)
74
+ try:
75
+ tenant = await api.resolve_tenant_id()
76
+ raw = await api.query(tenant_id=tenant, user_id=user_id, prompt=query, k=k)
77
+ memories: List[Dict[str, Any]] = []
78
+ for item in raw.get("memories") or []:
79
+ if not isinstance(item, dict):
80
+ continue
81
+ mem_id = str(item.get("memory_id") or item.get("id") or "")
82
+ text = str(item.get("raw_text") or item.get("value") or "").strip()
83
+ score = item.get("score")
84
+ if mem_id and text:
85
+ memories.append(
86
+ {
87
+ "id": mem_id,
88
+ "text": text,
89
+ "score": float(score) if isinstance(score, (int, float)) else None,
90
+ }
91
+ )
92
+ return memories
93
+ finally:
94
+ await api.aclose()
95
+
96
+
97
+ async def save_turn(
98
+ *,
99
+ user_id: str,
100
+ user: Optional[str] = None,
101
+ assistant: Optional[str] = None,
102
+ session_id: str = "default",
103
+ api_key: Optional[str] = None,
104
+ base_url: Optional[str] = None,
105
+ project_id: Optional[str] = None,
106
+ tenant_id: Optional[str] = None,
107
+ timeout: float = 30.0,
108
+ transport: Optional[httpx.AsyncBaseTransport] = None,
109
+ ) -> None:
110
+ """Persist one exchange to long-term memory explicitly. Idempotent:
111
+ retrying the same content for the same session stores nothing new
112
+ (same seeds as the session's ``long_term`` plane and every other
113
+ MemorySync adapter). RAISES on failure — an explicit persist call is
114
+ owed the truth about whether it worked."""
115
+ api = _api(api_key, base_url, project_id, tenant_id, timeout, transport)
116
+ try:
117
+ tenant = await api.resolve_tenant_id()
118
+ session = (session_id or "default").strip() or "default"
119
+ turns = []
120
+ if user and user.strip():
121
+ turns.append(("human", user.strip()))
122
+ if assistant and assistant.strip():
123
+ turns.append(("ai", assistant.strip()))
124
+ for role, text in turns:
125
+ await api.add_turn(
126
+ tenant_id=tenant,
127
+ user_id=user_id,
128
+ text=f"{role}: {text}",
129
+ speaker=f"{role}@{session}#h{fnv1a64(f'{role}:{text}')}",
130
+ metadata={"session_id": session},
131
+ source="openai-agents",
132
+ )
133
+ finally:
134
+ await api.aclose()
@@ -0,0 +1,155 @@
1
+ """Long-term memory context for agent instructions.
2
+
3
+ The Agents SDK's documented hook for dynamic system prompts is an
4
+ ``instructions`` callable: ``Callable[[RunContextWrapper, Agent], str |
5
+ Awaitable[str]]``. ``memory_instructions`` builds that callable — base
6
+ instructions plus a recalled-memory block for the user, fetched fresh on
7
+ every run.
8
+
9
+ Recall failure NEVER breaks the run: the callable falls back to the base
10
+ instructions and reports through ``on_error``. A memory outage that took
11
+ the agent down with it would be strictly worse than no memory.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Callable, Dict, List, Optional, Union
17
+
18
+ import httpx
19
+
20
+ from ._api import AsyncV1Api, resolve_api_key, resolve_base_url
21
+
22
+ DEFAULT_TEMPLATE = (
23
+ "Relevant memories about this user from previous conversations:\n{context}"
24
+ )
25
+
26
+ #: The prompt used for profile recall — stable so results cache well.
27
+ PROFILE_PROMPT = "profile overview: preferences, facts, context about this user"
28
+
29
+ RecallMode = str # "query" | "profile" | "full"
30
+
31
+ OnError = Callable[[str, BaseException], None]
32
+
33
+
34
+ def _default_on_error(stage: str, error: BaseException) -> None:
35
+ import logging
36
+
37
+ logging.getLogger("openai_agents_memorysync").warning("%s: %s", stage, error)
38
+
39
+
40
+ def _latest_user_text(context: Any) -> str:
41
+ """Best-effort text of the newest user message on the run context.
42
+
43
+ The instructions callable receives the RunContextWrapper, not the
44
+ input items, so the user's message is only reachable when the caller
45
+ put it there. ``memory_instructions`` therefore accepts an explicit
46
+ ``prompt`` resolver for precise recall, and falls back to profile
47
+ recall otherwise — a wrong guess about the query would recall the
48
+ wrong memories.
49
+ """
50
+ return ""
51
+
52
+
53
+ def memory_instructions(
54
+ base_instructions: str,
55
+ *,
56
+ user_id: Union[str, Callable[[Any], Optional[str]]],
57
+ mode: RecallMode = "profile",
58
+ k: Optional[int] = None,
59
+ template: str = DEFAULT_TEMPLATE,
60
+ prompt: Optional[Callable[[Any], Optional[str]]] = None,
61
+ api_key: Optional[str] = None,
62
+ base_url: Optional[str] = None,
63
+ project_id: Optional[str] = None,
64
+ tenant_id: Optional[str] = None,
65
+ timeout: float = 30.0,
66
+ transport: Optional[httpx.AsyncBaseTransport] = None,
67
+ on_error: Optional[OnError] = None,
68
+ ) -> Callable[[Any, Any], Any]:
69
+ """Build a dynamic-instructions callable that injects recalled memory.
70
+
71
+ ```python
72
+ agent = Agent(
73
+ name="Assistant",
74
+ instructions=memory_instructions(
75
+ "You are a helpful assistant.",
76
+ user_id="customer-7",
77
+ ),
78
+ )
79
+ ```
80
+
81
+ Args:
82
+ user_id: The end user to recall for — a fixed string, or a
83
+ callable receiving the RunContextWrapper (multi-user servers
84
+ resolve identity per request). A resolver returning ``None``
85
+ skips recall for that run.
86
+ mode: ``"profile"`` (default — an overview of the user; the right
87
+ recall when the query text is not available), ``"query"``
88
+ (recall for the text returned by ``prompt``), or ``"full"``
89
+ (both).
90
+ prompt: Optional callable receiving the RunContextWrapper and
91
+ returning the text to recall against, for ``query``/``full``.
92
+ """
93
+ if mode not in ("query", "profile", "full"):
94
+ raise ValueError(f"mode must be 'query', 'profile' or 'full', got {mode!r}")
95
+ handle_error = on_error or _default_on_error
96
+ api = AsyncV1Api(
97
+ api_key=resolve_api_key(api_key),
98
+ base_url=resolve_base_url(base_url),
99
+ project_id=project_id,
100
+ timeout=timeout,
101
+ transport=transport,
102
+ )
103
+ if tenant_id:
104
+ api.set_tenant_id(str(tenant_id))
105
+
106
+ async def _context_for(user: str, question: str) -> str:
107
+ tenant = await api.resolve_tenant_id()
108
+ blocks: List[str] = []
109
+ if mode in ("profile", "full") or (mode == "query" and not question):
110
+ profile = await api.recall(
111
+ tenant_id=tenant, user_id=user, prompt=PROFILE_PROMPT, k=k
112
+ )
113
+ text = str(profile.get("context") or "").strip()
114
+ if text:
115
+ blocks.append(text)
116
+ if mode in ("query", "full") and question:
117
+ relevant = await api.recall(
118
+ tenant_id=tenant, user_id=user, prompt=question, k=k
119
+ )
120
+ text = str(relevant.get("context") or "").strip()
121
+ if text and text not in blocks:
122
+ blocks.append(text)
123
+ return "\n".join(blocks)
124
+
125
+ async def instructions(wrapper: Any, agent: Any) -> str:
126
+ user: Optional[str]
127
+ if callable(user_id):
128
+ try:
129
+ user = user_id(wrapper)
130
+ except Exception as exc: # noqa: BLE001 — a broken resolver skips, never crashes
131
+ handle_error("identity", exc)
132
+ user = None
133
+ else:
134
+ user = user_id
135
+ if not user or not str(user).strip():
136
+ return base_instructions
137
+
138
+ question = ""
139
+ if prompt is not None:
140
+ try:
141
+ question = str(prompt(wrapper) or "").strip()
142
+ except Exception as exc: # noqa: BLE001
143
+ handle_error("recall", exc)
144
+ question = ""
145
+
146
+ try:
147
+ context = await _context_for(str(user).strip(), question)
148
+ except Exception as exc: # noqa: BLE001 — recall failure degrades, never breaks the run
149
+ handle_error("recall", exc)
150
+ return base_instructions
151
+ if not context.strip():
152
+ return base_instructions
153
+ return f"{base_instructions}\n\n{template.replace('{context}', context)}"
154
+
155
+ return instructions
File without changes