edgeone 1.6.24 → 1.6.26
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.
|
@@ -307,7 +307,11 @@ class AgentContext:
|
|
|
307
307
|
"通常是因为运行时未设置 store。"
|
|
308
308
|
)
|
|
309
309
|
from .memory import ConversationMemory
|
|
310
|
-
instance = ConversationMemory(
|
|
310
|
+
instance = ConversationMemory(
|
|
311
|
+
self._store_blob,
|
|
312
|
+
self.run_id,
|
|
313
|
+
self.conversation_id,
|
|
314
|
+
)
|
|
311
315
|
object.__setattr__(self, '_store_cached', instance)
|
|
312
316
|
return instance
|
|
313
317
|
|
|
@@ -67,6 +67,11 @@ class MemoryStorageError(MemoryError):
|
|
|
67
67
|
pass
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
class MemoryCorruptError(MemoryError):
|
|
71
|
+
"""Persisted framework state is malformed or incompatible."""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
70
75
|
# ─── Data Model ───
|
|
71
76
|
|
|
72
77
|
|
|
@@ -170,6 +175,48 @@ _MAX_SAFE_INTEGER = 9007199254740991 # JS Number.MAX_SAFE_INTEGER
|
|
|
170
175
|
# update_message 用它来判断 content / metadata 是否被传,对齐 Node 的
|
|
171
176
|
# `input.content === undefined` 判断。
|
|
172
177
|
_UNSET: Any = object()
|
|
178
|
+
_STATE_PREFIX = "state"
|
|
179
|
+
_MAX_STATE_KEY_LEN = 256
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _validate_state_key(key: str) -> None:
|
|
183
|
+
if not isinstance(key, str) or not key:
|
|
184
|
+
raise MemoryValidationError("state key must be a non-empty string")
|
|
185
|
+
if len(key) > _MAX_STATE_KEY_LEN:
|
|
186
|
+
raise MemoryValidationError(
|
|
187
|
+
f"state key exceeds maximum of {_MAX_STATE_KEY_LEN} characters"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validate_state_value(value: Any, path: str = "state value", seen=None) -> None:
|
|
192
|
+
if value is None or isinstance(value, (str, bool)):
|
|
193
|
+
return
|
|
194
|
+
if isinstance(value, (int, float)):
|
|
195
|
+
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
196
|
+
raise MemoryValidationError(f"{path} must contain only JSON values")
|
|
197
|
+
return
|
|
198
|
+
if seen is None:
|
|
199
|
+
seen = set()
|
|
200
|
+
if isinstance(value, (list, dict)):
|
|
201
|
+
identity = id(value)
|
|
202
|
+
if identity in seen:
|
|
203
|
+
raise MemoryValidationError(f"{path} must not contain circular references")
|
|
204
|
+
seen.add(identity)
|
|
205
|
+
if isinstance(value, list):
|
|
206
|
+
for index, item in enumerate(value):
|
|
207
|
+
_validate_state_value(item, f"{path}[{index}]", seen)
|
|
208
|
+
else:
|
|
209
|
+
for key, item in value.items():
|
|
210
|
+
if not isinstance(key, str):
|
|
211
|
+
raise MemoryValidationError(f"{path} keys must be strings")
|
|
212
|
+
_validate_state_value(item, f"{path}.{key}", seen)
|
|
213
|
+
seen.remove(identity)
|
|
214
|
+
return
|
|
215
|
+
raise MemoryValidationError(f"{path} must be JSON serializable")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _state_key(conversation_id: str, key: str) -> str:
|
|
219
|
+
return f"{_STATE_PREFIX}/{_encode_cid(conversation_id)}/{_encode_segment(key)}"
|
|
173
220
|
|
|
174
221
|
|
|
175
222
|
def _json_default(obj: Any) -> Any:
|
|
@@ -310,6 +357,52 @@ def _cursor_sort_key(last_message_at: int, conversation_id: str) -> str:
|
|
|
310
357
|
# ─── ConversationMemory ───
|
|
311
358
|
|
|
312
359
|
|
|
360
|
+
class _ConversationState:
|
|
361
|
+
"""Conversation-bound state facade persisted under the state/ prefix."""
|
|
362
|
+
|
|
363
|
+
def __init__(self, memory: "ConversationMemory") -> None:
|
|
364
|
+
self._memory = memory
|
|
365
|
+
|
|
366
|
+
def _conversation_id(self) -> str:
|
|
367
|
+
conversation_id = self._memory._conversation_id
|
|
368
|
+
if not conversation_id:
|
|
369
|
+
raise MemoryValidationError(
|
|
370
|
+
"State operations require a bound conversation_id"
|
|
371
|
+
)
|
|
372
|
+
self._memory._validate_conversation_id(conversation_id)
|
|
373
|
+
return conversation_id
|
|
374
|
+
|
|
375
|
+
async def get(self, key: str) -> Any:
|
|
376
|
+
_validate_state_key(key)
|
|
377
|
+
conversation_id = self._conversation_id()
|
|
378
|
+
raw = await self._memory._blob_get_text(_state_key(conversation_id, key))
|
|
379
|
+
if raw is None:
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
return json.loads(raw)
|
|
383
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
384
|
+
raise MemoryStorageError("Stored state value is invalid JSON") from exc
|
|
385
|
+
|
|
386
|
+
async def set(self, key: str, value: Any) -> None:
|
|
387
|
+
_validate_state_key(key)
|
|
388
|
+
conversation_id = self._conversation_id()
|
|
389
|
+
_validate_state_value(value)
|
|
390
|
+
try:
|
|
391
|
+
payload = json.dumps(
|
|
392
|
+
value, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
393
|
+
)
|
|
394
|
+
await self._memory._blob.set(_state_key(conversation_id, key), payload)
|
|
395
|
+
except MemoryValidationError:
|
|
396
|
+
raise
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
raise MemoryStorageError("Failed to write state value") from exc
|
|
399
|
+
|
|
400
|
+
async def delete(self, key: str) -> None:
|
|
401
|
+
_validate_state_key(key)
|
|
402
|
+
conversation_id = self._conversation_id()
|
|
403
|
+
await self._memory._blob_delete(_state_key(conversation_id, key))
|
|
404
|
+
|
|
405
|
+
|
|
313
406
|
class ConversationMemory:
|
|
314
407
|
"""Conversation message history CRUD, accessed as ctx.store.
|
|
315
408
|
|
|
@@ -317,16 +410,24 @@ class ConversationMemory:
|
|
|
317
410
|
All core methods are async.
|
|
318
411
|
"""
|
|
319
412
|
|
|
320
|
-
def __init__(
|
|
413
|
+
def __init__(
|
|
414
|
+
self,
|
|
415
|
+
blob_store: Any,
|
|
416
|
+
run_id: str,
|
|
417
|
+
conversation_id: Optional[str] = None,
|
|
418
|
+
) -> None:
|
|
321
419
|
"""
|
|
322
420
|
Args:
|
|
323
421
|
blob_store: Raw blob store instance (pages_blob.Store or LocalFileBlobStore).
|
|
324
422
|
Must implement: get(key, type=), set(key, value), delete(key),
|
|
325
423
|
list(prefix=) returning object with .blobs list of objects with .key.
|
|
326
424
|
run_id: Current run ID, auto-injected into message metadata.
|
|
425
|
+
conversation_id: Optional bound conversation for state and default sessions.
|
|
327
426
|
"""
|
|
328
427
|
self._blob = blob_store
|
|
329
428
|
self._run_id = run_id
|
|
429
|
+
self._conversation_id = conversation_id
|
|
430
|
+
self._state_cached = _ConversationState(self)
|
|
330
431
|
|
|
331
432
|
# ─── Validation Helpers ───
|
|
332
433
|
|
|
@@ -400,6 +501,22 @@ class ConversationMemory:
|
|
|
400
501
|
except Exception as e:
|
|
401
502
|
raise MemoryStorageError(f"Failed to read key '{key}': {e}") from e
|
|
402
503
|
|
|
504
|
+
async def _blob_get_json_strict(self, key: str, **kwargs) -> Optional[dict]:
|
|
505
|
+
"""Read JSON and distinguish corrupt state from a missing key."""
|
|
506
|
+
try:
|
|
507
|
+
raw = await self._blob.get(key, type="text", **kwargs)
|
|
508
|
+
if raw is None:
|
|
509
|
+
return None
|
|
510
|
+
return json.loads(raw)
|
|
511
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
512
|
+
raise MemoryCorruptError(
|
|
513
|
+
f"Persisted framework state is corrupt for key '{key}'"
|
|
514
|
+
) from exc
|
|
515
|
+
except MemoryStorageError:
|
|
516
|
+
raise
|
|
517
|
+
except Exception as e:
|
|
518
|
+
raise MemoryStorageError(f"Failed to read key '{key}': {e}") from e
|
|
519
|
+
|
|
403
520
|
async def _blob_set_json(self, key: str, data: Any) -> None:
|
|
404
521
|
"""Serialize value to JSON and write to blob store.
|
|
405
522
|
|
|
@@ -1196,6 +1313,9 @@ class ConversationMemory:
|
|
|
1196
1313
|
)
|
|
1197
1314
|
|
|
1198
1315
|
if meta_data is None:
|
|
1316
|
+
await self._delete_bound_state(conversation_id)
|
|
1317
|
+
await self._delete_claude_session_for_conversation(conversation_id)
|
|
1318
|
+
await self.langgraph_checkpointer.adelete_thread(conversation_id)
|
|
1199
1319
|
raise MemoryNotFoundError(f"Conversation '{conversation_id}' not found")
|
|
1200
1320
|
|
|
1201
1321
|
meta = ConversationMeta.from_dict(meta_data)
|
|
@@ -1215,6 +1335,12 @@ class ConversationMemory:
|
|
|
1215
1335
|
|
|
1216
1336
|
await asyncio.gather(*delete_ops)
|
|
1217
1337
|
|
|
1338
|
+
# Keep LangGraph Store items intact while removing checkpoints and
|
|
1339
|
+
# conversation-bound state for the current bound conversation.
|
|
1340
|
+
await self._delete_bound_state(conversation_id)
|
|
1341
|
+
await self._delete_claude_session_for_conversation(conversation_id)
|
|
1342
|
+
await self.langgraph_checkpointer.adelete_thread(conversation_id)
|
|
1343
|
+
|
|
1218
1344
|
async def update_conversation(
|
|
1219
1345
|
self, conversation_id: str, metadata: dict
|
|
1220
1346
|
) -> ConversationMeta:
|
|
@@ -1271,6 +1397,46 @@ class ConversationMemory:
|
|
|
1271
1397
|
|
|
1272
1398
|
# ─── Framework Helpers ───
|
|
1273
1399
|
|
|
1400
|
+
@property
|
|
1401
|
+
def state(self) -> _ConversationState:
|
|
1402
|
+
"""Conversation-bound JSON state facade."""
|
|
1403
|
+
return self._state_cached
|
|
1404
|
+
|
|
1405
|
+
async def _delete_bound_state(self, conversation_id: str) -> None:
|
|
1406
|
+
if self._conversation_id != conversation_id:
|
|
1407
|
+
return
|
|
1408
|
+
keys = await self._blob_list_keys(
|
|
1409
|
+
f"{_STATE_PREFIX}/{_encode_cid(conversation_id)}/",
|
|
1410
|
+
consistency="strong",
|
|
1411
|
+
)
|
|
1412
|
+
for key in keys:
|
|
1413
|
+
await self._blob_delete(key)
|
|
1414
|
+
|
|
1415
|
+
async def _delete_claude_session_for_conversation(self, conversation_id: str) -> None:
|
|
1416
|
+
mapping_key = _claude_session_mapping_key(conversation_id)
|
|
1417
|
+
mapping = await self._blob_get_json_strict(mapping_key)
|
|
1418
|
+
session_id: Optional[str] = None
|
|
1419
|
+
if isinstance(mapping, str) and mapping:
|
|
1420
|
+
session_id = mapping
|
|
1421
|
+
elif isinstance(mapping, dict) and isinstance(mapping.get("session_id"), str):
|
|
1422
|
+
session_id = mapping["session_id"]
|
|
1423
|
+
elif isinstance(mapping, dict) and isinstance(mapping.get("sessionId"), str):
|
|
1424
|
+
session_id = mapping["sessionId"]
|
|
1425
|
+
if not session_id:
|
|
1426
|
+
session_id = _normalize_claude_uuid(conversation_id)
|
|
1427
|
+
|
|
1428
|
+
await self._blob_delete(mapping_key)
|
|
1429
|
+
if not session_id:
|
|
1430
|
+
return
|
|
1431
|
+
|
|
1432
|
+
keys = await self._blob_list_keys(f"{_CLAUDE_SESSION_PREFIX}/")
|
|
1433
|
+
encoded_session_id = _encode_segment(session_id)
|
|
1434
|
+
for key in keys:
|
|
1435
|
+
remainder = key[len(f"{_CLAUDE_SESSION_PREFIX}/"):]
|
|
1436
|
+
segments = remainder.split("/")
|
|
1437
|
+
if len(segments) >= 3 and segments[1] == encoded_session_id:
|
|
1438
|
+
await self._blob_delete(key)
|
|
1439
|
+
|
|
1274
1440
|
@staticmethod
|
|
1275
1441
|
def to_openai_input(messages: List[Message]) -> List[dict]:
|
|
1276
1442
|
"""Convert Messages to OpenAI-compatible format.
|
|
@@ -1326,7 +1492,7 @@ class ConversationMemory:
|
|
|
1326
1492
|
self._langgraph_store_cached = adapter
|
|
1327
1493
|
return adapter
|
|
1328
1494
|
|
|
1329
|
-
def openai_session(self, session_id: str, *, max_items: int = 100) -> "_EdgeOneMemorySession":
|
|
1495
|
+
def openai_session(self, session_id: Optional[str] = None, *, max_items: int = 100) -> "_EdgeOneMemorySession":
|
|
1330
1496
|
"""Create an OpenAI Agents SDK Session backed by ctx.store.
|
|
1331
1497
|
|
|
1332
1498
|
Usage with OpenAI Agents SDK::
|
|
@@ -1350,12 +1516,50 @@ class ConversationMemory:
|
|
|
1350
1516
|
session_id: Conversation/session identifier (typically ctx.conversation_id).
|
|
1351
1517
|
max_items: Maximum items to retrieve per get_items() call (default 100).
|
|
1352
1518
|
"""
|
|
1353
|
-
|
|
1519
|
+
resolved_session_id = (
|
|
1520
|
+
session_id if session_id is not None else self._conversation_id
|
|
1521
|
+
)
|
|
1522
|
+
if not resolved_session_id:
|
|
1523
|
+
raise MemoryValidationError(
|
|
1524
|
+
"openai_session requires a session_id or bound conversation_id"
|
|
1525
|
+
)
|
|
1526
|
+
if session_id is None:
|
|
1527
|
+
self._validate_conversation_id(resolved_session_id)
|
|
1528
|
+
return _EdgeOneMemorySession(self, resolved_session_id, max_items=max_items)
|
|
1354
1529
|
|
|
1355
|
-
def session(self, session_id: str, *, max_items: int = 100) -> "_EdgeOneMemorySession":
|
|
1530
|
+
def session(self, session_id: Optional[str] = None, *, max_items: int = 100) -> "_EdgeOneMemorySession":
|
|
1356
1531
|
"""Alias for :meth:`openai_session` (backward compatibility)."""
|
|
1357
1532
|
return self.openai_session(session_id, max_items=max_items)
|
|
1358
1533
|
|
|
1534
|
+
async def claude_session_binding(self, conversation_id: Optional[str] = None) -> str:
|
|
1535
|
+
"""Return a stable Claude SDK UUID for a conversation identifier."""
|
|
1536
|
+
resolved_conversation_id = (
|
|
1537
|
+
conversation_id if conversation_id is not None else self._conversation_id
|
|
1538
|
+
)
|
|
1539
|
+
if not resolved_conversation_id:
|
|
1540
|
+
raise MemoryValidationError(
|
|
1541
|
+
"claude_session_binding requires a conversation_id or bound conversation_id"
|
|
1542
|
+
)
|
|
1543
|
+
self._validate_conversation_id(resolved_conversation_id)
|
|
1544
|
+
mapping_key = _claude_session_mapping_key(resolved_conversation_id)
|
|
1545
|
+
mapping = await self._blob_get_json_strict(mapping_key)
|
|
1546
|
+
if isinstance(mapping, str) and mapping:
|
|
1547
|
+
return mapping
|
|
1548
|
+
if isinstance(mapping, dict):
|
|
1549
|
+
mapped_session_id = mapping.get("session_id") or mapping.get("sessionId")
|
|
1550
|
+
if isinstance(mapped_session_id, str) and mapped_session_id:
|
|
1551
|
+
return mapped_session_id
|
|
1552
|
+
|
|
1553
|
+
session_id = _normalize_claude_uuid(resolved_conversation_id) or str(uuid.uuid4())
|
|
1554
|
+
await self._blob_set_json(
|
|
1555
|
+
mapping_key,
|
|
1556
|
+
{
|
|
1557
|
+
"conversation_id": resolved_conversation_id,
|
|
1558
|
+
"session_id": session_id,
|
|
1559
|
+
},
|
|
1560
|
+
)
|
|
1561
|
+
return session_id
|
|
1562
|
+
|
|
1359
1563
|
def claude_session_store(self) -> "EdgeOneSessionStore":
|
|
1360
1564
|
"""Create a Claude Agent SDK SessionStore backed by EdgeOne blob storage.
|
|
1361
1565
|
|
|
@@ -1878,7 +2082,7 @@ class _LangGraphCheckpointerAdapter(_BaseCheckpointSaver):
|
|
|
1878
2082
|
if not checkpoint_id:
|
|
1879
2083
|
return None
|
|
1880
2084
|
|
|
1881
|
-
data = await self._memory.
|
|
2085
|
+
data = await self._memory._blob_get_json_strict(
|
|
1882
2086
|
self._checkpoint_key(thread_id, str(checkpoint_id), checkpoint_ns),
|
|
1883
2087
|
consistency="strong",
|
|
1884
2088
|
)
|
|
@@ -1926,13 +2130,10 @@ class _LangGraphCheckpointerAdapter(_BaseCheckpointSaver):
|
|
|
1926
2130
|
|
|
1927
2131
|
# Concurrent read all writes
|
|
1928
2132
|
async def _read_write(key: str):
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
return json.loads(raw)
|
|
1934
|
-
except Exception:
|
|
1935
|
-
return None
|
|
2133
|
+
return await self._memory._blob_get_json_strict(
|
|
2134
|
+
key,
|
|
2135
|
+
consistency="strong",
|
|
2136
|
+
)
|
|
1936
2137
|
|
|
1937
2138
|
results = await asyncio.gather(*[_read_write(k) for k in keys])
|
|
1938
2139
|
|
|
@@ -1950,14 +2151,24 @@ class _LangGraphCheckpointerAdapter(_BaseCheckpointSaver):
|
|
|
1950
2151
|
for channel_name, channel_value in writes:
|
|
1951
2152
|
pending_writes.append((task_id, channel_name, channel_value))
|
|
1952
2153
|
continue
|
|
1953
|
-
except Exception:
|
|
1954
|
-
|
|
2154
|
+
except Exception as exc:
|
|
2155
|
+
raise MemoryCorruptError(
|
|
2156
|
+
"LangGraph pending writes are corrupt or incompatible"
|
|
2157
|
+
) from exc
|
|
1955
2158
|
|
|
1956
2159
|
# Legacy format: writes stored as plain JSON
|
|
1957
2160
|
writes_legacy = wd.get("writes")
|
|
1958
2161
|
if writes_legacy:
|
|
1959
|
-
|
|
1960
|
-
|
|
2162
|
+
if not isinstance(writes_legacy, list):
|
|
2163
|
+
raise MemoryCorruptError(
|
|
2164
|
+
"LangGraph pending writes are corrupt or incompatible"
|
|
2165
|
+
)
|
|
2166
|
+
for write in writes_legacy:
|
|
2167
|
+
if not isinstance(write, (list, tuple)) or len(write) < 2:
|
|
2168
|
+
raise MemoryCorruptError(
|
|
2169
|
+
"LangGraph pending writes are corrupt or incompatible"
|
|
2170
|
+
)
|
|
2171
|
+
pending_writes.append((task_id, write[0], write[1]))
|
|
1961
2172
|
|
|
1962
2173
|
return pending_writes
|
|
1963
2174
|
|
|
@@ -2062,7 +2273,7 @@ class _LangGraphCheckpointerAdapter(_BaseCheckpointSaver):
|
|
|
2062
2273
|
keys = keys[:limit]
|
|
2063
2274
|
tuples = []
|
|
2064
2275
|
for key in keys:
|
|
2065
|
-
data = await self._memory.
|
|
2276
|
+
data = await self._memory._blob_get_json_strict(key)
|
|
2066
2277
|
if data is not None:
|
|
2067
2278
|
tuples.append(_to_checkpoint_tuple(data))
|
|
2068
2279
|
return tuples
|
|
@@ -2280,6 +2491,18 @@ class _EdgeOneMemorySession:
|
|
|
2280
2491
|
|
|
2281
2492
|
|
|
2282
2493
|
_CLAUDE_SESSION_PREFIX = "claude_sessions"
|
|
2494
|
+
_CLAUDE_SESSION_MAPPING_PREFIX = "claude_session_mapping"
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def _claude_session_mapping_key(conversation_id: str) -> str:
|
|
2498
|
+
return f"{_CLAUDE_SESSION_MAPPING_PREFIX}/{_encode_segment(conversation_id)}"
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
def _normalize_claude_uuid(value: str) -> Optional[str]:
|
|
2502
|
+
try:
|
|
2503
|
+
return str(uuid.UUID(value)).lower()
|
|
2504
|
+
except (TypeError, ValueError, AttributeError):
|
|
2505
|
+
return None
|
|
2283
2506
|
|
|
2284
2507
|
|
|
2285
2508
|
class EdgeOneSessionStore:
|
|
@@ -2387,14 +2610,20 @@ class EdgeOneSessionStore:
|
|
|
2387
2610
|
entries: list = []
|
|
2388
2611
|
for k in keys:
|
|
2389
2612
|
raw = await self._blob.get(k, type="text")
|
|
2613
|
+
if raw is not None and not isinstance(raw, str):
|
|
2614
|
+
raise MemoryCorruptError(
|
|
2615
|
+
f"Claude session part '{k}' is not valid JSONL text"
|
|
2616
|
+
)
|
|
2390
2617
|
if raw:
|
|
2391
2618
|
for line in raw.split("\n"):
|
|
2392
2619
|
line = line.strip()
|
|
2393
2620
|
if line:
|
|
2394
2621
|
try:
|
|
2395
2622
|
entries.append(json.loads(line))
|
|
2396
|
-
except (json.JSONDecodeError, ValueError):
|
|
2397
|
-
|
|
2623
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
2624
|
+
raise MemoryCorruptError(
|
|
2625
|
+
f"Claude session part '{k}' contains malformed JSONL"
|
|
2626
|
+
) from exc
|
|
2398
2627
|
return entries if entries else None
|
|
2399
2628
|
|
|
2400
2629
|
# ─── Optional Methods ───
|