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(self._store_blob, self.run_id)
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__(self, blob_store: Any, run_id: str) -> None:
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
- return _EdgeOneMemorySession(self, session_id, max_items=max_items)
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._blob_get_json(
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
- try:
1930
- raw = await self._memory._blob.get(key, type="text", consistency="strong")
1931
- if raw is None:
1932
- return None
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
- pass
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
- for channel_name, channel_value in writes_legacy:
1960
- pending_writes.append((task_id, channel_name, channel_value))
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._blob_get_json(key)
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
- continue
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 ───
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edgeone",
3
- "version": "1.6.24",
3
+ "version": "1.6.26",
4
4
  "description": "Command-line interface for TencentCloud Pages Functions",
5
5
  "bin": {
6
6
  "edgeone": "./edgeone-bin/edgeone.js"