edgeone 1.6.1 → 1.6.3-beta.1

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.
@@ -257,6 +257,18 @@ def _generate_message_id() -> str:
257
257
  return f"msg_{uuid.uuid4().hex[:16]}"
258
258
 
259
259
 
260
+ def _extract_message_id_from_key(key: str) -> str:
261
+ """Extract messageId from a message key.
262
+
263
+ Key format: conversations/{cid}/messages/{created_at}_{message_id}
264
+ The message_id part starts with 'msg_' so we split on the first '_' after
265
+ the timestamp to get everything after it.
266
+ """
267
+ basename = key.rsplit("/", 1)[-1] # "{created_at}_{message_id}"
268
+ parts = basename.split("_", 1)
269
+ return parts[1] if len(parts) > 1 else ""
270
+
271
+
260
272
  # ─── Cursor Helpers ───
261
273
 
262
274
 
@@ -424,10 +436,19 @@ class ConversationMemory:
424
436
  except Exception as e:
425
437
  raise MemoryStorageError(f"Failed to delete key '{key}': {e}") from e
426
438
 
427
- async def _blob_list_keys(self, prefix: str, **kwargs) -> List[str]:
428
- """List all keys with given prefix, sorted lexicographically."""
439
+ async def _blob_list_keys(self, prefix: str, *, limit: Optional[int] = None, **kwargs) -> List[str]:
440
+ """List keys with given prefix, sorted lexicographically.
441
+
442
+ Args:
443
+ prefix: Key prefix to filter.
444
+ limit: Max keys to return (None = all). Passed to blob SDK.
445
+ """
429
446
  try:
430
- result = await self._blob.list(prefix=prefix, **kwargs)
447
+ list_kwargs = {"prefix": prefix, **kwargs}
448
+ if limit is not None:
449
+ list_kwargs["limit"] = limit
450
+ list_kwargs["paginate"] = False
451
+ result = await self._blob.list(**list_kwargs)
431
452
  return [blob.key for blob in result.blobs]
432
453
  except MemoryStorageError:
433
454
  raise
@@ -523,46 +544,53 @@ class ConversationMemory:
523
544
  metadata=metadata if metadata else None,
524
545
  )
525
546
 
526
- # Write message blob
527
- msg_key = _message_key(encoded_cid, now_ms, message_id)
528
- await self._blob_set_json(msg_key, message.to_dict())
547
+ # --- Parallel writes (no data dependency between these) ---
548
+ import asyncio
529
549
 
530
- # Write message_index for O(1) lookup by message_id (mirrors Node memory.ts).
531
- await self._blob_set_json(
532
- _message_index_key(message_id),
533
- {
534
- "conversationId": conversation_id,
535
- "key": msg_key,
536
- "messageId": message_id,
537
- "createdAt": now_ms,
538
- },
539
- )
550
+ msg_key = _message_key(encoded_cid, now_ms, message_id)
551
+ new_index_key = _index_key(now_ms, encoded_cid)
552
+ index_entry = {"conversationId": conversation_id, "lastMessageAt": now_ms}
540
553
 
541
- # Update conversation meta
554
+ # Update conversation meta (prepare before write)
542
555
  meta.last_message_at = now_ms
543
556
  meta.message_count += 1
544
- await self._blob_set_json(meta_k, meta.to_dict())
545
-
546
- # Update conversation index: write new first, then delete old (safety)
547
- new_index_key = _index_key(now_ms, encoded_cid)
548
- index_entry = {"conversationId": conversation_id, "lastMessageAt": now_ms}
549
- await self._blob_set_json(new_index_key, index_entry)
550
557
 
551
- if old_last_message_at is not None and old_last_message_at != now_ms:
552
- old_index_key = _index_key(old_last_message_at, encoded_cid)
553
- await self._blob_delete(old_index_key)
558
+ # Batch write: message body + message index + meta + conversation index
559
+ write_tasks = [
560
+ self._blob_set_json(msg_key, message.to_dict()),
561
+ self._blob_set_json(
562
+ _message_index_key(message_id),
563
+ {
564
+ "conversationId": conversation_id,
565
+ "key": msg_key,
566
+ "messageId": message_id,
567
+ "createdAt": now_ms,
568
+ },
569
+ ),
570
+ self._blob_set_json(meta_k, meta.to_dict()),
571
+ self._blob_set_json(new_index_key, index_entry),
572
+ ]
554
573
 
555
- # Update user conversation index (if user_id provided)
574
+ # User conversation index (if user_id provided)
556
575
  if user_id is not None:
557
576
  encoded_user_id = _encode_segment(user_id)
558
577
  new_user_key = _user_index_key(encoded_user_id, now_ms, encoded_cid)
559
578
  index_entry_user = {"conversationId": conversation_id, "lastMessageAt": now_ms}
560
- await self._blob_set_json(new_user_key, index_entry_user)
579
+ write_tasks.append(self._blob_set_json(new_user_key, index_entry_user))
561
580
 
562
- # Delete old user index key for this user+conversation (if exists and different)
581
+ await asyncio.gather(*write_tasks)
582
+
583
+ # --- Cleanup old index keys (can also be parallel) ---
584
+ delete_tasks = []
585
+ if old_last_message_at is not None and old_last_message_at != now_ms:
586
+ old_index_key = _index_key(old_last_message_at, encoded_cid)
587
+ delete_tasks.append(self._blob_delete(old_index_key))
588
+
589
+ if user_id is not None:
590
+ encoded_user_id = _encode_segment(user_id)
563
591
  if old_last_message_at is not None and old_last_message_at != now_ms:
564
592
  old_user_key = _user_index_key(encoded_user_id, old_last_message_at, encoded_cid)
565
- await self._blob_delete(old_user_key)
593
+ delete_tasks.append(self._blob_delete(old_user_key))
566
594
  elif old_last_message_at is None:
567
595
  # First message in this conversation — scan for stale user index
568
596
  # (edge case: conversation was deleted + recreated under same id)
@@ -570,19 +598,121 @@ class ConversationMemory:
570
598
  existing_keys = await self._blob_list_keys(prefix)
571
599
  suffix = f"_{encoded_cid}"
572
600
  for k in existing_keys:
573
- if k.endswith(suffix) and k != new_user_key:
574
- await self._blob_delete(k)
601
+ if k.endswith(suffix) and k != _user_index_key(encoded_user_id, now_ms, encoded_cid):
602
+ delete_tasks.append(self._blob_delete(k))
603
+
604
+ if delete_tasks:
605
+ await asyncio.gather(*delete_tasks)
575
606
 
576
607
  return message_id
577
608
 
609
+ async def _bulk_append_messages(
610
+ self,
611
+ conversation_id: str,
612
+ messages_data: List[dict],
613
+ ) -> List[str]:
614
+ """Internal bulk append: write N messages with only 1 meta/index update.
615
+
616
+ Args:
617
+ conversation_id: Conversation identifier.
618
+ messages_data: List of dicts with keys: role, content, metadata.
619
+
620
+ Returns:
621
+ List of generated message_ids.
622
+ """
623
+ import asyncio
624
+
625
+ if not messages_data:
626
+ return []
627
+
628
+ encoded_cid = _encode_cid(conversation_id)
629
+ now_ms = int(time.time() * 1000)
630
+
631
+ # Load meta once
632
+ meta_k = _meta_key(encoded_cid)
633
+ meta_data = await self._blob_get_json(meta_k)
634
+
635
+ if meta_data is not None:
636
+ meta = ConversationMeta.from_dict(meta_data)
637
+ if meta.message_count + len(messages_data) > _MAX_MESSAGES_PER_CONVERSATION:
638
+ raise MemoryQuotaExceededError(
639
+ f"Conversation '{conversation_id}' would exceed {_MAX_MESSAGES_PER_CONVERSATION} messages"
640
+ )
641
+ old_last_message_at = meta.last_message_at
642
+ else:
643
+ meta = ConversationMeta(
644
+ conversation_id=conversation_id,
645
+ created_at=now_ms,
646
+ last_message_at=0,
647
+ message_count=0,
648
+ metadata=None,
649
+ )
650
+ old_last_message_at = None
651
+
652
+ # Build all messages and collect write tasks
653
+ write_tasks = []
654
+ message_ids = []
655
+ last_ts = now_ms
656
+
657
+ for i, msg_data in enumerate(messages_data):
658
+ # Increment timestamp by 1ms per message to ensure unique keys + ordering
659
+ ts = now_ms + i
660
+ last_ts = ts
661
+ message_id = _generate_message_id()
662
+ message_ids.append(message_id)
663
+
664
+ msg_metadata = dict(msg_data.get("metadata") or {})
665
+ if "run_id" not in msg_metadata:
666
+ msg_metadata["run_id"] = self._run_id
667
+
668
+ message = Message(
669
+ message_id=message_id,
670
+ role=msg_data["role"],
671
+ content=msg_data["content"],
672
+ created_at=ts,
673
+ metadata=msg_metadata if msg_metadata else None,
674
+ )
675
+
676
+ msg_key = _message_key(encoded_cid, ts, message_id)
677
+ write_tasks.append(self._blob_set_json(msg_key, message.to_dict()))
678
+ write_tasks.append(self._blob_set_json(
679
+ _message_index_key(message_id),
680
+ {"conversationId": conversation_id, "key": msg_key, "messageId": message_id, "createdAt": ts},
681
+ ))
682
+
683
+ # Update meta once (for all messages)
684
+ meta.last_message_at = last_ts
685
+ meta.message_count += len(messages_data)
686
+ write_tasks.append(self._blob_set_json(meta_k, meta.to_dict()))
687
+
688
+ # Update conversation index once
689
+ new_index_key = _index_key(last_ts, encoded_cid)
690
+ write_tasks.append(self._blob_set_json(
691
+ new_index_key, {"conversationId": conversation_id, "lastMessageAt": last_ts}
692
+ ))
693
+
694
+ # Parallel write all
695
+ await asyncio.gather(*write_tasks)
696
+
697
+ # Cleanup old index
698
+ if old_last_message_at is not None and old_last_message_at != last_ts:
699
+ await self._blob_delete(_index_key(old_last_message_at, encoded_cid))
700
+
701
+ return message_ids
702
+
578
703
  async def _check_idempotency(self, encoded_cid: str, idempotency_key: str) -> Optional[str]:
579
704
  """Scan recent messages for matching idempotency_key. Returns message_id if found."""
705
+ import asyncio
706
+
580
707
  prefix = _messages_prefix(encoded_cid)
581
708
  keys = await self._blob_list_keys(prefix)
582
- # Scan at most last 50 messages for bounded cost
583
- check_count = min(len(keys), 50)
584
- for key in keys[-check_count:]:
585
- data = await self._blob_get_json(key)
709
+ # Scan at most last 50 messages — parallel fetch for performance
710
+ check_keys = keys[-min(len(keys), 50):]
711
+ if not check_keys:
712
+ return None
713
+
714
+ results = await asyncio.gather(*[self._blob_get_json(k) for k in check_keys])
715
+ for data in results:
586
716
  if data and isinstance(data.get("metadata"), dict):
587
717
  if data["metadata"].get("idempotency_key") == idempotency_key:
588
718
  return data.get("messageId") or data.get("message_id", "")
@@ -766,26 +896,31 @@ class ConversationMemory:
766
896
  raise MemoryNotFoundError(f"Conversation '{conversation_id}' not found")
767
897
  previous_meta = ConversationMeta.from_dict(meta_data)
768
898
 
769
- # 删消息本体 + 二级索引
770
- await self._blob_delete(msg_key)
771
- await self._blob_delete(_message_index_key(message_id))
899
+ import asyncio
900
+
901
+ # 删消息本体 + 二级索引 (parallel)
902
+ await asyncio.gather(
903
+ self._blob_delete(msg_key),
904
+ self._blob_delete(_message_index_key(message_id)),
905
+ )
772
906
 
773
907
  # 重算 meta:扫剩余消息得到最新 last_message_at;空则回退到 created_at
774
908
  next_meta = await self._recalculate_conversation_meta(encoded_cid, previous_meta)
775
- await self._blob_set_json(meta_k, next_meta.to_dict())
776
909
 
777
- # 更新 conversation_index:写新(如果有变动)+ 删旧
910
+ # 更新 meta + conversation_index (parallel)
778
911
  new_index_key = _index_key(next_meta.last_message_at, encoded_cid)
779
912
  old_index_key = _index_key(previous_meta.last_message_at, encoded_cid)
913
+ write_ops: list = [self._blob_set_json(meta_k, next_meta.to_dict())]
780
914
  if new_index_key != old_index_key:
781
- await self._blob_set_json(
915
+ write_ops.append(self._blob_set_json(
782
916
  new_index_key,
783
917
  {
784
918
  "conversationId": conversation_id,
785
919
  "lastMessageAt": next_meta.last_message_at,
786
920
  },
787
- )
788
- await self._blob_delete(old_index_key)
921
+ ))
922
+ write_ops.append(self._blob_delete(old_index_key))
923
+ await asyncio.gather(*write_ops)
789
924
 
790
925
  async def _recalculate_conversation_meta(
791
926
  self,
@@ -830,6 +965,8 @@ class ConversationMemory:
830
965
  Raises:
831
966
  MemoryNotFoundError: conversation 不存在。
832
967
  """
968
+ import asyncio
969
+
833
970
  self._validate_conversation_id(conversation_id)
834
971
 
835
972
  encoded_cid = _encode_cid(conversation_id)
@@ -841,17 +978,21 @@ class ConversationMemory:
841
978
 
842
979
  previous_meta = ConversationMeta.from_dict(meta_data)
843
980
 
844
- # 删所有消息正文 + 对应二级索引
981
+ # Delete all message bodies + message_index entries in parallel.
982
+ # Extract messageId from key name (no need to read message body).
845
983
  prefix = _messages_prefix(encoded_cid)
846
984
  keys = await self._blob_list_keys(prefix)
847
- for key in keys:
848
- data = await self._blob_get_json(key)
849
- await self._blob_delete(key)
850
- if data and isinstance(data.get("messageId") or data.get("message_id"), str):
851
- await self._blob_delete(_message_index_key(data.get("messageId") or data.get("message_id", "")))
852
985
 
853
- # 重置 meta:保留 conversation_id / created_at / 自定义 metadata,
854
- # message_count 置 0,last_message_at 回退到 created_at。
986
+ delete_ops: list = []
987
+ for key in keys:
988
+ delete_ops.append(self._blob_delete(key))
989
+ msg_id = _extract_message_id_from_key(key)
990
+ if msg_id:
991
+ delete_ops.append(self._blob_delete(_message_index_key(msg_id)))
992
+ if delete_ops:
993
+ await asyncio.gather(*delete_ops)
994
+
995
+ # 重置 meta + 更新 conversation_index in parallel
855
996
  next_meta = ConversationMeta(
856
997
  conversation_id=previous_meta.conversation_id,
857
998
  created_at=previous_meta.created_at,
@@ -859,20 +1000,20 @@ class ConversationMemory:
859
1000
  message_count=0,
860
1001
  metadata=previous_meta.metadata,
861
1002
  )
862
- await self._blob_set_json(meta_k, next_meta.to_dict())
863
-
864
- # 更新 conversation_index
865
1003
  new_index_key = _index_key(next_meta.last_message_at, encoded_cid)
866
1004
  old_index_key = _index_key(previous_meta.last_message_at, encoded_cid)
1005
+
1006
+ write_ops: list = [self._blob_set_json(meta_k, next_meta.to_dict())]
867
1007
  if new_index_key != old_index_key:
868
- await self._blob_set_json(
1008
+ write_ops.append(self._blob_set_json(
869
1009
  new_index_key,
870
1010
  {
871
1011
  "conversationId": conversation_id,
872
1012
  "lastMessageAt": next_meta.last_message_at,
873
1013
  },
874
- )
875
- await self._blob_delete(old_index_key)
1014
+ ))
1015
+ write_ops.append(self._blob_delete(old_index_key))
1016
+ await asyncio.gather(*write_ops)
876
1017
 
877
1018
  async def list_conversations(
878
1019
  self,
@@ -908,9 +1049,19 @@ class ConversationMemory:
908
1049
  else:
909
1050
  prefix = _index_prefix()
910
1051
 
911
- # List all index keys (lexicographic ascending = most recent first with revTs)
912
- # Use strong consistency to align with Node behavior (avoid CDN stale data)
913
- keys = await self._blob_list_keys(prefix, consistency="strong")
1052
+ # Optimization: in the default case (desc order, no before cursor, using after or fresh),
1053
+ # blob list's natural lexicographic order = most-recent-first (reversed timestamp),
1054
+ # so we can pass limit directly to avoid full scan.
1055
+ can_use_sdk_limit = (order == "desc" and before is None)
1056
+
1057
+ if can_use_sdk_limit:
1058
+ # Request limit+1 extra to detect hasMore; if using after cursor,
1059
+ # we can't easily skip via SDK cursor, so fetch more and filter client-side.
1060
+ # For the common first-page case (no after), this is optimal.
1061
+ fetch_limit = limit + 1 if after is None else None
1062
+ keys = await self._blob_list_keys(prefix, consistency="strong", limit=fetch_limit)
1063
+ else:
1064
+ keys = await self._blob_list_keys(prefix, consistency="strong")
914
1065
 
915
1066
  # Default order is desc (most recent first). With revTs, ascending lex order
916
1067
  # already means most-recent-first, so desc = natural order, asc = reversed.
@@ -929,26 +1080,35 @@ class ConversationMemory:
929
1080
  cursor_sk = _cursor_sort_key(cursor_data["lastMessageAt"], cursor_data["conversationId"])
930
1081
  keys = self._apply_before_cursor(keys, prefix_len, cursor_sk, order)
931
1082
 
932
- # Fetch up to limit+1 to determine if there's a next page (handles skipped residuals)
933
- items: List[ConversationMeta] = []
934
- has_more = False
1083
+ # Parse encoded_cids from key basenames, take limit+1 to detect next page
1084
+ import asyncio
1085
+
1086
+ candidate_cids: List[str] = []
935
1087
  for key in keys:
936
- if len(items) >= limit:
937
- has_more = True
1088
+ if len(candidate_cids) > limit:
938
1089
  break
939
- # Parse encoded_cid from key basename: {revTs:016d}_{encoded_cid}
940
1090
  remainder = key[prefix_len:]
941
1091
  sep_idx = remainder.find("_")
942
1092
  if sep_idx < 0:
943
1093
  continue
944
- encoded_cid = remainder[sep_idx + 1:]
945
- meta_data = await self._blob_get_json(_meta_key(encoded_cid), consistency="strong")
1094
+ candidate_cids.append(remainder[sep_idx + 1:])
1095
+
1096
+ # Fetch metas in parallel (aligned with Node Promise.all approach)
1097
+ meta_results = await asyncio.gather(
1098
+ *[self._blob_get_json(_meta_key(cid), consistency="strong") for cid in candidate_cids]
1099
+ )
1100
+
1101
+ items: List[ConversationMeta] = []
1102
+ has_more = False
1103
+ for meta_data in meta_results:
1104
+ if len(items) >= limit:
1105
+ has_more = True
1106
+ break
946
1107
  if meta_data:
947
1108
  try:
948
1109
  items.append(ConversationMeta.from_dict(meta_data))
949
1110
  except (KeyError, TypeError):
950
- continue # skip corrupted/incompatible meta entries
951
- # else: residual index (conversation deleted), skip silently
1111
+ continue
952
1112
 
953
1113
  # Build cursors
954
1114
  next_cursor: Optional[str] = None
@@ -1021,32 +1181,39 @@ class ConversationMemory:
1021
1181
  Raises:
1022
1182
  MemoryNotFoundError: Conversation doesn't exist.
1023
1183
  """
1184
+ import asyncio
1185
+
1024
1186
  self._validate_conversation_id(conversation_id)
1025
1187
 
1026
1188
  encoded_cid = _encode_cid(conversation_id)
1027
1189
  meta_k = _meta_key(encoded_cid)
1028
- meta_data = await self._blob_get_json(meta_k)
1190
+
1191
+ # Read meta + list messages in parallel
1192
+ prefix = _messages_prefix(encoded_cid)
1193
+ meta_data, keys = await asyncio.gather(
1194
+ self._blob_get_json(meta_k),
1195
+ self._blob_list_keys(prefix),
1196
+ )
1029
1197
 
1030
1198
  if meta_data is None:
1031
1199
  raise MemoryNotFoundError(f"Conversation '{conversation_id}' not found")
1032
1200
 
1033
1201
  meta = ConversationMeta.from_dict(meta_data)
1034
1202
 
1035
- # Delete all message blobs + secondary indices
1036
- prefix = _messages_prefix(encoded_cid)
1037
- keys = await self._blob_list_keys(prefix)
1203
+ # Delete all message bodies + message_index entries in parallel.
1204
+ # Extract messageId from key name (no need to read message body).
1205
+ delete_ops: list = []
1038
1206
  for key in keys:
1039
- data = await self._blob_get_json(key)
1040
- await self._blob_delete(key)
1041
- if data and isinstance(data.get("messageId") or data.get("message_id"), str):
1042
- await self._blob_delete(_message_index_key(data.get("messageId") or data.get("message_id", "")))
1207
+ delete_ops.append(self._blob_delete(key))
1208
+ msg_id = _extract_message_id_from_key(key)
1209
+ if msg_id:
1210
+ delete_ops.append(self._blob_delete(_message_index_key(msg_id)))
1043
1211
 
1044
- # Delete index entry
1045
- old_index_key = _index_key(meta.last_message_at, encoded_cid)
1046
- await self._blob_delete(old_index_key)
1212
+ # Delete meta + conversation index entry
1213
+ delete_ops.append(self._blob_delete(meta_k))
1214
+ delete_ops.append(self._blob_delete(_index_key(meta.last_message_at, encoded_cid)))
1047
1215
 
1048
- # Delete meta
1049
- await self._blob_delete(meta_k)
1216
+ await asyncio.gather(*delete_ops)
1050
1217
 
1051
1218
  async def update_conversation(
1052
1219
  self, conversation_id: str, metadata: dict
@@ -1400,13 +1567,13 @@ class _LangGraphStoreAdapter(_LangGraphBaseStore):
1400
1567
  prefix = self._search_prefix(ns_prefix)
1401
1568
  listed = await self._memory._blob.list(prefix=prefix, consistency="strong")
1402
1569
  keys = [blob.key for blob in listed.blobs]
1403
- items: list[dict] = []
1404
- for bk in keys:
1405
- if f"/{_LANGGRAPH_STORE_KEY_SEPARATOR}/" not in bk:
1406
- continue
1407
- it = await self._parse_stored_item(bk)
1408
- if it and self._namespace_starts_with(it["namespace"], ns_prefix):
1409
- items.append(it)
1570
+ # Filter keys that contain the separator, then parallel fetch
1571
+ valid_keys = [bk for bk in keys if f"/{_LANGGRAPH_STORE_KEY_SEPARATOR}/" in bk]
1572
+ items_raw = await asyncio.gather(*[self._parse_stored_item(bk) for bk in valid_keys])
1573
+ items: list[dict] = [
1574
+ it for it in items_raw
1575
+ if it and self._namespace_starts_with(it["namespace"], ns_prefix)
1576
+ ]
1410
1577
  filtered = [it for it in items if self._matches_filter(it, getattr(op, "filter", None))]
1411
1578
  filtered.sort(key=lambda it: ("/".join(it["namespace"]), it["key"]))
1412
1579
  offset = getattr(op, "offset", 0) or 0
@@ -1431,11 +1598,12 @@ class _LangGraphStoreAdapter(_LangGraphBaseStore):
1431
1598
  all_prefix = f"{_LANGGRAPH_STORE_PREFIX}/items/"
1432
1599
  listed = await self._memory._blob.list(prefix=all_prefix, consistency="strong")
1433
1600
  keys = [blob.key for blob in listed.blobs]
1601
+ # Filter keys with separator, then parallel fetch
1602
+ valid_keys = [bk for bk in keys if f"/{_LANGGRAPH_STORE_KEY_SEPARATOR}/" in bk]
1603
+ items_raw = await asyncio.gather(*[self._parse_stored_item(bk) for bk in valid_keys])
1604
+
1434
1605
  namespaces: set[str] = set()
1435
- for bk in keys:
1436
- if f"/{_LANGGRAPH_STORE_KEY_SEPARATOR}/" not in bk:
1437
- continue
1438
- it = await self._parse_stored_item(bk)
1606
+ for it in items_raw:
1439
1607
  if not it:
1440
1608
  continue
1441
1609
  ns = [str(s) for s in it["namespace"]]
@@ -2031,18 +2199,21 @@ class _EdgeOneMemorySession:
2031
2199
  if not items:
2032
2200
  return
2033
2201
 
2202
+ # Use bulk append for efficiency (1 meta update instead of N)
2203
+ messages_data = []
2034
2204
  for item in items:
2035
2205
  normalized = self._jsonable(item)
2036
2206
  role = self._role_for_item(normalized)
2037
- await self.memory.append_message(
2038
- self.session_id,
2039
- role,
2040
- normalized,
2041
- metadata={
2207
+ messages_data.append({
2208
+ "role": role,
2209
+ "content": normalized,
2210
+ "metadata": {
2042
2211
  **_SESSION_METADATA_MARKER,
2043
2212
  "item_type": normalized.get("type") if isinstance(normalized, dict) else None,
2044
2213
  },
2045
- )
2214
+ })
2215
+
2216
+ await self.memory._bulk_append_messages(self.session_id, messages_data)
2046
2217
 
2047
2218
  async def pop_item(self) -> Optional[dict]:
2048
2219
  """Remove and return the most recent item from the session."""
@@ -12,7 +12,7 @@ from dataclasses import dataclass
12
12
  from typing import Any, AsyncIterator, Callable, Awaitable, Optional
13
13
  from uuid import uuid4
14
14
 
15
- from .context import AgentContext, AbortActiveRunResult, RequestInfo, SandboxRuntimeError, StreamResponse
15
+ from .context import AgentContext, AbortActiveRunResult, EoContext, RequestInfo, SandboxRuntimeError, StreamResponse
16
16
 
17
17
 
18
18
  # LangGraph 通过抛出这些异常实现 graph 暂停 / 控制流跳转,并非业务错误。
@@ -238,10 +238,12 @@ class AgentsApi:
238
238
  headers=_context.request.headers,
239
239
  query=_context.request.query,
240
240
  signal=_context.request.signal,
241
+ eo=_context.request.eo,
241
242
  ),
242
243
  env=_context.env,
243
244
  kv=self._store_resolver(route_path),
244
245
  agents=self,
246
+ eo=_context.eo,
245
247
  # active_run_id 不向 child 传递:child 是「当前 run 内部派生的子调用」,
246
248
  # 沿用 parent 的 active_run_id 反而会让业务误以为有冲突。
247
249
  active_run_id=None,
@@ -377,6 +379,7 @@ class AgentRuntime:
377
379
  headers: dict,
378
380
  query: dict,
379
381
  body: dict,
382
+ eo: Optional[EoContext] = None,
380
383
  ) -> RuntimeResult:
381
384
  # Route match — user handler takes priority
382
385
  entry = self._registry.get(path)
@@ -406,18 +409,17 @@ class AgentRuntime:
406
409
  return RuntimeResult(404, {"code": "AGENT_NOT_FOUND", "message": f"No handler for {path}"}, {})
407
410
 
408
411
  # Parse IDs
409
- # 与 Node runtime 对齐:当前请求自身的 conversation_id 只从 header 读,
410
- # **不再回退** ``body.conversation_id`` —— 否则 /stop 这种请求自然会把
411
- # body 里的「目标 conversation_id」当成自己的 conversation,进而覆盖
412
- # active_signals 里被取消方的注册,导致 ctx.utils.abortActiveRun 失效。
413
- # (body 里的 conversation_id 应该由业务 handler 自行解析作为参数。)
412
+ # 与 Node runtime 对齐:conversation_id 必须从 header 提供,不自动生成。
413
+ # 不带 makers-conversation-id header 的请求直接返回 400,与 Node 行为一致。
414
414
  header_conversation_id = _header(headers, "makers-conversation-id") or _header(headers, "conversation-id")
415
- if header_conversation_id:
416
- conversation_id = header_conversation_id
417
- conversation_id_source = "makers-conversation-id"
418
- else:
419
- conversation_id = str(uuid4())
420
- conversation_id_source = "generated"
415
+ if not header_conversation_id:
416
+ _log(f"Agent error: {path} code=AGENT_CONVERSATION_ID_REQUIRED")
417
+ return RuntimeResult(400, {
418
+ "code": "AGENT_CONVERSATION_ID_REQUIRED",
419
+ "message": "conversationId is required. Pass makers-conversation-id header.",
420
+ }, {})
421
+ conversation_id = header_conversation_id
422
+ conversation_id_source = "makers-conversation-id"
421
423
  run_id = str(uuid4())
422
424
  start_time = time.monotonic()
423
425
 
@@ -442,15 +444,19 @@ class AgentRuntime:
442
444
  "makers-run-id": run_id,
443
445
  }
444
446
 
447
+ # Normalize eo
448
+ _eo = eo or EoContext()
449
+
445
450
  # Build context
446
451
  context = AgentContext(
447
452
  conversation_id=conversation_id,
448
453
  run_id=run_id,
449
454
  parent_run_id=None,
450
- request=RequestInfo(body=body, headers=headers, query=query, signal=cancel_event),
455
+ request=RequestInfo(body=body, headers=headers, query=query, signal=cancel_event, eo=_eo),
451
456
  env=dict(os.environ),
452
457
  kv=self._store_resolver(path),
453
458
  agents=self._agents_api,
459
+ eo=_eo,
454
460
  active_run_id=existing_active_run_id,
455
461
  _store_blob=self._store_blob,
456
462
  _tracer=_get_obs_tracer(),