sycommon-python-lib 0.2.5a36__py3-none-any.whl → 0.2.5a38__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.
@@ -104,27 +104,35 @@ def _patch_agent_streaming(agent):
104
104
  from langchain_core.messages import AIMessageChunk as _AIC, AIMessage as _AIM
105
105
  from langchain_core.runnables.config import var_child_runnable_config
106
106
 
107
- print(f"[StreamingPatch] agent.nodes: {list(agent.nodes.keys())}", flush=True)
107
+ print(
108
+ f"[StreamingPatch] agent.nodes: {list(agent.nodes.keys())}", flush=True)
108
109
  node = agent.nodes.get("model")
109
110
  if node is None:
110
111
  print("[StreamingPatch] model node not found, skip", flush=True)
111
- SYLogger.warning("[DeepAgent] model node not found, skip streaming patch")
112
+ SYLogger.warning(
113
+ "[DeepAgent] model node not found, skip streaming patch")
112
114
  return
113
115
 
114
116
  inner = node.node # RunnableSeq
115
- print(f"[StreamingPatch] inner type: {type(inner).__name__}", flush=True)
117
+ print(
118
+ f"[StreamingPatch] inner type: {type(inner).__name__}", flush=True)
116
119
  if not hasattr(inner, 'steps') or not inner.steps:
117
120
  print(f"[StreamingPatch] no steps, inner={inner}", flush=True)
118
- SYLogger.warning("[DeepAgent] model node has no steps, skip streaming patch")
121
+ SYLogger.warning(
122
+ "[DeepAgent] model node has no steps, skip streaming patch")
119
123
  return
120
124
 
121
- step0 = inner.steps[0] # RunnableCallable(trace=False, afunc=amodel_node)
122
- print(f"[StreamingPatch] step0 type: {type(step0).__name__}, trace={getattr(step0, 'trace', 'N/A')}", flush=True)
125
+ # RunnableCallable(trace=False, afunc=amodel_node)
126
+ step0 = inner.steps[0]
127
+ print(
128
+ f"[StreamingPatch] step0 type: {type(step0).__name__}, trace={getattr(step0, 'trace', 'N/A')}", flush=True)
123
129
  afunc = getattr(step0, "afunc", None) # amodel_node
124
130
  if afunc is None or afunc.__closure__ is None:
125
- SYLogger.warning("[DeepAgent] amodel_node afunc/closure 缺失, skip streaming patch")
131
+ SYLogger.warning(
132
+ "[DeepAgent] amodel_node afunc/closure 缺失, skip streaming patch")
126
133
  return
127
- print(f"[StreamingPatch] afunc: {afunc.__name__}, freevars={afunc.__code__.co_freevars}", flush=True)
134
+ print(
135
+ f"[StreamingPatch] afunc: {afunc.__name__}, freevars={afunc.__code__.co_freevars}", flush=True)
128
136
 
129
137
  # ---- 按自由变量名定位 cell(不依赖固定索引)----
130
138
  def _cell_by_name(fn, name):
@@ -192,7 +200,8 @@ def _patch_agent_streaming(agent):
192
200
  f"[StreamingPatch] clamp max_tokens: "
193
201
  f"context={max_tokens_limit}, max_output={max_output} → {dynamic_max}")
194
202
  except Exception as e:
195
- SYLogger.warning(f"[StreamingPatch] clamp max_tokens failed: {e}")
203
+ SYLogger.warning(
204
+ f"[StreamingPatch] clamp max_tokens failed: {e}")
196
205
 
197
206
  async def _patched_execute_model_async(request):
198
207
  """替代 factory._execute_model_async: 用 astream + config 实现流式回调。"""
@@ -219,7 +228,8 @@ def _patch_agent_streaming(agent):
219
228
  _summary_text = re.sub(
220
229
  r'</?internal_context_data>', '', _raw, flags=re.DOTALL
221
230
  ).strip()
222
- SYLogger.debug("[DeepAgent] 摘要已从 HumanMessage 提取,将注入 system_message")
231
+ SYLogger.debug(
232
+ "[DeepAgent] 摘要已从 HumanMessage 提取,将注入 system_message")
223
233
  else:
224
234
  _filtered_messages.append(_msg)
225
235
  messages = _filtered_messages
@@ -233,7 +243,8 @@ def _patch_agent_streaming(agent):
233
243
  f"{_summary_text}\n"
234
244
  "</system_context>"
235
245
  )
236
- _new_sys = append_to_system_message(request.system_message, _summary_directive)
246
+ _new_sys = append_to_system_message(
247
+ request.system_message, _summary_directive)
237
248
  request = request.override(system_message=_new_sys)
238
249
 
239
250
  if request.system_message:
@@ -254,7 +265,8 @@ def _patch_agent_streaming(agent):
254
265
 
255
266
  if not chunks:
256
267
  # astream 无产出时降级到原始 ainvoke(不走流式)
257
- SYLogger.warning("[StreamingPatch] astream returned empty, fallback to ainvoke")
268
+ SYLogger.warning(
269
+ "[StreamingPatch] astream returned empty, fallback to ainvoke")
258
270
  output = await model_.ainvoke(messages, config=config)
259
271
  else:
260
272
  generation = chunks[0]
@@ -268,8 +280,10 @@ def _patch_agent_streaming(agent):
268
280
  content=ai_message.content,
269
281
  additional_kwargs=ai_message.additional_kwargs,
270
282
  tool_calls=ai_message.tool_calls,
271
- usage_metadata=getattr(ai_message, "usage_metadata", None),
272
- response_metadata=getattr(ai_message, "response_metadata", {}),
283
+ usage_metadata=getattr(
284
+ ai_message, "usage_metadata", None),
285
+ response_metadata=getattr(
286
+ ai_message, "response_metadata", {}),
273
287
  id=ai_message.id,
274
288
  )
275
289
  output = ai_message
@@ -277,7 +291,8 @@ def _patch_agent_streaming(agent):
277
291
  if _name:
278
292
  output.name = _name
279
293
 
280
- handled_output = _handle_model_output(output, effective_response_format)
294
+ handled_output = _handle_model_output(
295
+ output, effective_response_format)
281
296
  return ModelResponse(
282
297
  result=handled_output["messages"],
283
298
  structured_response=handled_output.get("structured_response"),
@@ -289,7 +304,6 @@ def _patch_agent_streaming(agent):
289
304
  f"[DeepAgent] _execute_model_async patched (astream + config) "
290
305
  f"| langchain={_cur.get('langchain')} core={_cur.get('langchain_core')}")
291
306
 
292
-
293
307
  except Exception as e:
294
308
  SYLogger.warning(f"[DeepAgent] streaming patch failed: {e}")
295
309
 
@@ -437,7 +451,8 @@ class DeepAgent:
437
451
  input_state = {"messages": [HumanMessage(content=message)]}
438
452
  if rubric:
439
453
  input_state["rubric"] = rubric
440
- print(f"[DeepAgent] chat() starting stream, message={repr(message[:80])}", flush=True)
454
+ print(
455
+ f"[DeepAgent] chat() starting stream, message={repr(message[:80])}", flush=True)
441
456
  async for chunk in self._astream_with_retry(
442
457
  input_state,
443
458
  max_retries=3,
@@ -457,9 +472,12 @@ class DeepAgent:
457
472
  _meta = chunk[1]
458
473
  _msg_type = type(_msg).__name__
459
474
  _content = repr(str(getattr(_msg, 'content', ''))[:80])
460
- _node = _meta.get('langgraph_node', '?') if isinstance(_meta, dict) else '?'
461
- _step = _meta.get('langgraph_step', '?') if isinstance(_meta, dict) else '?'
462
- print(f"[DeepAgent] 2tuple: ns={_ns} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
475
+ _node = _meta.get('langgraph_node', '?') if isinstance(
476
+ _meta, dict) else '?'
477
+ _step = _meta.get('langgraph_step', '?') if isinstance(
478
+ _meta, dict) else '?'
479
+ print(
480
+ f"[DeepAgent] 2tuple: ns={_ns} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
463
481
  elif isinstance(chunk, tuple) and _chunk_len == 3:
464
482
  namespace_p, _tag_p, payload_p = chunk
465
483
  if isinstance(payload_p, tuple) and len(payload_p) == 2:
@@ -467,13 +485,18 @@ class DeepAgent:
467
485
  _meta = payload_p[1]
468
486
  _msg_type = type(_msg).__name__
469
487
  _content = repr(str(getattr(_msg, 'content', ''))[:80])
470
- _node = _meta.get('langgraph_node', '?') if isinstance(_meta, dict) else '?'
471
- _step = _meta.get('langgraph_step', '?') if isinstance(_meta, dict) else '?'
472
- print(f"[DeepAgent] 3tuple: ns={repr(namespace_p)[:30]} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
488
+ _node = _meta.get('langgraph_node', '?') if isinstance(
489
+ _meta, dict) else '?'
490
+ _step = _meta.get('langgraph_step', '?') if isinstance(
491
+ _meta, dict) else '?'
492
+ print(
493
+ f"[DeepAgent] 3tuple: ns={repr(namespace_p)[:30]} msg={_msg_type} node={_node} step={_step} content={_content}", flush=True)
473
494
  else:
474
- print(f"[DeepAgent] 3tuple: payload_type={type(payload_p).__name__}", flush=True)
495
+ print(
496
+ f"[DeepAgent] 3tuple: payload_type={type(payload_p).__name__}", flush=True)
475
497
  else:
476
- print(f"[DeepAgent] chunk: type={type(chunk).__name__}", flush=True)
498
+ print(
499
+ f"[DeepAgent] chunk: type={type(chunk).__name__}", flush=True)
477
500
  # 检查取消
478
501
  if cancel_event and cancel_event.is_set():
479
502
  event = ChatEventBuilder.cancelled("任务已取消")
@@ -506,10 +529,13 @@ class DeepAgent:
506
529
 
507
530
  # ===== 🔑 诊断日志:详细记录每个 chunk =====
508
531
  _chunk_type = type(chunk).__name__
509
- _msg_type = getattr(msg, '__class__.__name__', '?') if msg else 'None'
532
+ _msg_type = getattr(
533
+ msg, '__class__.__name__', '?') if msg else 'None'
510
534
  _ns = namespace if namespace else '()'
511
- _node = metadata.get("langgraph_node", "?") if metadata else "?"
512
- _step = metadata.get("langgraph_step", "?") if metadata else "?"
535
+ _node = metadata.get(
536
+ "langgraph_node", "?") if metadata else "?"
537
+ _step = metadata.get(
538
+ "langgraph_step", "?") if metadata else "?"
513
539
  _content_preview = ""
514
540
  if isinstance(msg, BaseMessage):
515
541
  _c = msg.content or ""
@@ -531,8 +557,10 @@ class DeepAgent:
531
557
  if msg_type in ("AIMessage", "AIMessageChunk"):
532
558
  usage_meta = getattr(msg, "usage_metadata", None)
533
559
  if usage_meta:
534
- total_input_tokens += usage_meta.get("input_tokens", 0)
535
- total_output_tokens += usage_meta.get("output_tokens", 0)
560
+ total_input_tokens += usage_meta.get(
561
+ "input_tokens", 0)
562
+ total_output_tokens += usage_meta.get(
563
+ "output_tokens", 0)
536
564
  if usage_meta.get("input_tokens", 0) > 0:
537
565
  SYLogger.debug(
538
566
  f"[DeepAgent] usage_metadata | input={usage_meta.get('input_tokens', 0)} "
@@ -611,10 +639,12 @@ class DeepAgent:
611
639
  await on_event(event)
612
640
  yield event
613
641
 
614
- print(f"[DeepAgent] post-yield: msg_type={type(msg).__name__}, is_BaseMessage={isinstance(msg, BaseMessage)}", flush=True)
642
+ print(
643
+ f"[DeepAgent] post-yield: msg_type={type(msg).__name__}, is_BaseMessage={isinstance(msg, BaseMessage)}", flush=True)
615
644
 
616
645
  if not isinstance(msg, BaseMessage):
617
- print(f"[DeepAgent] SKIPPING non-BaseMessage: {type(msg).__name__}", flush=True)
646
+ print(
647
+ f"[DeepAgent] SKIPPING non-BaseMessage: {type(msg).__name__}", flush=True)
618
648
  continue
619
649
 
620
650
  msg_type = msg.__class__.__name__
@@ -623,14 +653,16 @@ class DeepAgent:
623
653
  # SummarizationMiddleware 会将摘要包装为 HumanMessage(lc_source="summarization"),
624
654
  # 这仅用于模型内部理解上下文,不应展示给用户
625
655
  if msg_type == "HumanMessage" and getattr(msg, "additional_kwargs", {}).get("lc_source") == "summarization":
626
- SYLogger.debug(f"[DeepAgent] 跳过摘要消息 (lc_source=summarization)")
656
+ SYLogger.debug(
657
+ f"[DeepAgent] 跳过摘要消息 (lc_source=summarization)")
627
658
  continue
628
659
 
629
660
  raw_content = msg.content
630
661
  # 🔑 处理 content 为 list 的情况(多模态模型)
631
662
  if isinstance(raw_content, list):
632
663
  content = "".join(
633
- item.get("text", "") if isinstance(item, dict) else str(item)
664
+ item.get("text", "") if isinstance(
665
+ item, dict) else str(item)
634
666
  for item in raw_content
635
667
  )
636
668
  else:
@@ -639,15 +671,20 @@ class DeepAgent:
639
671
  # 🔑 Defense 3: 清理 AI 输出中的摘要泄漏标签(安全网)
640
672
  # 即使摘要已注入 SystemMessage,模型仍可能在极少数情况下回显标签
641
673
  if msg_type in ("AIMessage", "AIMessageChunk") and content:
674
+ _cleaned = False
642
675
  for _pat in [
643
676
  r'<internal_context_data>.*?</internal_context_data>',
644
677
  r'<compressed_history>.*?</compressed_history>',
645
678
  r'<system_context>.*?</system_context>',
646
679
  ]:
647
680
  if re.search(_pat, content, re.DOTALL):
648
- content = re.sub(_pat, '', content, flags=re.DOTALL)
681
+ content = re.sub(
682
+ _pat, '', content, flags=re.DOTALL)
683
+ _cleaned = True
649
684
  SYLogger.warning("[DeepAgent] AI 输出包含摘要泄漏标签,已清理")
650
- content = content.strip()
685
+ # 仅在确实清理了泄漏标签时才 strip,避免吞掉正常的换行符
686
+ if _cleaned:
687
+ content = content.strip()
651
688
 
652
689
  if msg_type in ("AIMessage", "AIMessageChunk"):
653
690
  # 🔑 提取 reasoning_content(思考/推理内容)
@@ -683,11 +720,13 @@ class DeepAgent:
683
720
  # 用 (id 或 index) 匹配已记录的 tool_call
684
721
  if tcc_id:
685
722
  existing_tc = next(
686
- (tc for tc in current_tool_calls if tc.get("id") == tcc_id), None
723
+ (tc for tc in current_tool_calls if tc.get(
724
+ "id") == tcc_id), None
687
725
  )
688
726
  elif tcc_index is not None:
689
727
  existing_tc = next(
690
- (tc for tc in current_tool_calls if tc.get("_index") == tcc_index), None
728
+ (tc for tc in current_tool_calls if tc.get(
729
+ "_index") == tcc_index), None
691
730
  )
692
731
  else:
693
732
  existing_tc = None
@@ -764,7 +803,8 @@ class DeepAgent:
764
803
  # 非空 args 覆盖(完整解析后的结果)
765
804
  if tc_args and tc_args != {}:
766
805
  existing_tc["args"] = tc_args
767
- existing_tc["_args_buffer"] = json.dumps(tc_args, ensure_ascii=False)
806
+ existing_tc["_args_buffer"] = json.dumps(
807
+ tc_args, ensure_ascii=False)
768
808
  else:
769
809
  current_tool_calls.append({
770
810
  "id": tc_id, "name": tc_name, "args": tc_args,
@@ -844,7 +884,8 @@ class DeepAgent:
844
884
  SYLogger.debug(
845
885
  f"[DeepAgent] AI chunk done | {repr(ai_chunk_buffer[:100])}...")
846
886
 
847
- print(f"[DeepAgent] FOR LOOP ENDED. stream_step={stream_step}, ai_text_content={repr(ai_text_content[:100])}, tool_calls={len(current_tool_calls)}", flush=True)
887
+ print(
888
+ f"[DeepAgent] FOR LOOP ENDED. stream_step={stream_step}, ai_text_content={repr(ai_text_content[:100])}, tool_calls={len(current_tool_calls)}", flush=True)
848
889
 
849
890
  # 空响应检测:模型被调用但没有产出任何文本
850
891
  if not ai_text_content and not ai_chunk_buffer:
@@ -884,7 +925,8 @@ class DeepAgent:
884
925
  yield event
885
926
  raise
886
927
  except Exception as e:
887
- print(f"[DeepAgent] chat() Exception: {type(e).__name__}: {e}", flush=True)
928
+ print(
929
+ f"[DeepAgent] chat() Exception: {type(e).__name__}: {e}", flush=True)
888
930
  import traceback
889
931
  traceback.print_exc()
890
932
  SYLogger.error(f"[DeepAgent] 聊天异常: {e}")
@@ -936,66 +978,76 @@ class DeepAgent:
936
978
  max_sandbox_retries = 3
937
979
 
938
980
  try:
939
- while True:
940
- for attempt in range(max_retries):
941
- try:
942
- print(f"[DeepAgent] _astream_with_retry: attempt={attempt}, starting astream...", flush=True)
943
- chunk_count = 0
944
- async for chunk in self.agent.astream(
945
- input_data,
946
- config=self.config,
947
- stream_mode="messages",
948
- subgraphs=True
949
- ):
950
- chunk_count += 1
951
- print(f"[DeepAgent] _astream yielding chunk #{chunk_count}", flush=True)
952
- yield chunk
953
- print(f"[DeepAgent] _astream yield returned, consumer processed chunk #{chunk_count}", flush=True)
954
- print(f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
955
- return
956
- except (APIConnectionError, APIError, APITimeoutError, ConnectionError, httpx.RemoteProtocolError) as e:
957
- last_error = e
958
- print(f"[DeepAgent] _astream_with_retry: API error: {type(e).__name__}: {e}", flush=True)
959
- if attempt < max_retries - 1:
960
- delay = base_delay * (2 ** attempt)
961
- SYLogger.warning(
962
- f"[DeepAgent] 连接错误: {e}, {delay}秒后重试...")
963
- await asyncio.sleep(delay)
964
- else:
965
- raise last_error
966
- except RuntimeError as e:
967
- err_msg = str(e)
968
- is_sandbox_error = "沙箱服务不可用" in err_msg or "Failed to download" in err_msg
969
- if is_sandbox_error and self.recovery_manager and sandbox_retries < max_sandbox_retries:
970
- sandbox_retries += 1
971
- SYLogger.warning(f"[DeepAgent] 沙箱服务不可用,尝试恢复 ({sandbox_retries}/{max_sandbox_retries})...")
972
- recovered = await self.recovery_manager.recover()
973
- if recovered:
974
- SYLogger.info("[DeepAgent] 沙箱已恢复,继续执行")
975
- break
976
- raise
977
- raise
978
- except ValueError as e:
979
- err_msg = str(e)
980
- if "Failed to download" in err_msg or "沙箱服务不可用" in err_msg:
981
- if self.recovery_manager and sandbox_retries < max_sandbox_retries:
981
+ while True:
982
+ for attempt in range(max_retries):
983
+ try:
984
+ print(
985
+ f"[DeepAgent] _astream_with_retry: attempt={attempt}, starting astream...", flush=True)
986
+ chunk_count = 0
987
+ async for chunk in self.agent.astream(
988
+ input_data,
989
+ config=self.config,
990
+ stream_mode="messages",
991
+ subgraphs=True
992
+ ):
993
+ chunk_count += 1
994
+ print(
995
+ f"[DeepAgent] _astream yielding chunk #{chunk_count}", flush=True)
996
+ yield chunk
997
+ print(
998
+ f"[DeepAgent] _astream yield returned, consumer processed chunk #{chunk_count}", flush=True)
999
+ print(
1000
+ f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
1001
+ return
1002
+ except (APIConnectionError, APIError, APITimeoutError, ConnectionError, httpx.RemoteProtocolError) as e:
1003
+ last_error = e
1004
+ print(
1005
+ f"[DeepAgent] _astream_with_retry: API error: {type(e).__name__}: {e}", flush=True)
1006
+ if attempt < max_retries - 1:
1007
+ delay = base_delay * (2 ** attempt)
1008
+ SYLogger.warning(
1009
+ f"[DeepAgent] 连接错误: {e}, {delay}秒后重试...")
1010
+ await asyncio.sleep(delay)
1011
+ else:
1012
+ raise last_error
1013
+ except RuntimeError as e:
1014
+ err_msg = str(e)
1015
+ is_sandbox_error = "沙箱服务不可用" in err_msg or "Failed to download" in err_msg
1016
+ if is_sandbox_error and self.recovery_manager and sandbox_retries < max_sandbox_retries:
982
1017
  sandbox_retries += 1
983
- SYLogger.warning(f"[DeepAgent] Memory 下载失败(沙箱不可达),尝试恢复 ({sandbox_retries}/{max_sandbox_retries})...")
1018
+ SYLogger.warning(
1019
+ f"[DeepAgent] 沙箱服务不可用,尝试恢复 ({sandbox_retries}/{max_sandbox_retries})...")
984
1020
  recovered = await self.recovery_manager.recover()
985
1021
  if recovered:
986
1022
  SYLogger.info("[DeepAgent] 沙箱已恢复,继续执行")
987
1023
  break
988
- # 恢复失败也继续执行(降级:memory 不可用不阻塞聊天)
989
- SYLogger.warning(f"[DeepAgent] Memory 不可用,降级继续执行: {err_msg}")
990
- continue
991
- raise
992
- else:
993
- break
1024
+ raise
1025
+ raise
1026
+ except ValueError as e:
1027
+ err_msg = str(e)
1028
+ if "Failed to download" in err_msg or "沙箱服务不可用" in err_msg:
1029
+ if self.recovery_manager and sandbox_retries < max_sandbox_retries:
1030
+ sandbox_retries += 1
1031
+ SYLogger.warning(
1032
+ f"[DeepAgent] Memory 下载失败(沙箱不可达),尝试恢复 ({sandbox_retries}/{max_sandbox_retries})...")
1033
+ recovered = await self.recovery_manager.recover()
1034
+ if recovered:
1035
+ SYLogger.info("[DeepAgent] 沙箱已恢复,继续执行")
1036
+ break
1037
+ # 恢复失败也继续执行(降级:memory 不可用不阻塞聊天)
1038
+ SYLogger.warning(
1039
+ f"[DeepAgent] Memory 不可用,降级继续执行: {err_msg}")
1040
+ continue
1041
+ raise
1042
+ else:
1043
+ break
994
1044
  except GeneratorExit:
995
- print("[DeepAgent] _astream_with_retry: GeneratorExit! Consumer closed the generator!", flush=True)
1045
+ print(
1046
+ "[DeepAgent] _astream_with_retry: GeneratorExit! Consumer closed the generator!", flush=True)
996
1047
  raise
997
1048
  except Exception as e:
998
- print(f"[DeepAgent] _astream_with_retry: Unexpected exception: {type(e).__name__}: {e}", flush=True)
1049
+ print(
1050
+ f"[DeepAgent] _astream_with_retry: Unexpected exception: {type(e).__name__}: {e}", flush=True)
999
1051
  raise
1000
1052
 
1001
1053
 
@@ -1047,7 +1099,8 @@ async def create_deep_agent(
1047
1099
  # 等方法在原始 ChatOpenAI 上工作,而不是被 LLMWithTokenTracking 的包装拦截。
1048
1100
  raw_model = getattr(model, 'llm', model)
1049
1101
  if raw_model is not model:
1050
- print(f"[DeepAgent] 提取原始模型: {type(model).__name__} → {type(raw_model).__name__}", flush=True)
1102
+ print(
1103
+ f"[DeepAgent] 提取原始模型: {type(model).__name__} → {type(raw_model).__name__}", flush=True)
1051
1104
  if hasattr(raw_model, 'streaming'):
1052
1105
  raw_model.streaming = True
1053
1106
 
@@ -1136,10 +1189,12 @@ async def create_deep_agent(
1136
1189
 
1137
1190
  RunnableCallable.__init__ = _patched_rc_init
1138
1191
  except Exception as _patch_err:
1139
- SYLogger.warning(f"[DeepAgent] RunnableCallable monkey-patch FAILED: {_patch_err}")
1192
+ SYLogger.warning(
1193
+ f"[DeepAgent] RunnableCallable monkey-patch FAILED: {_patch_err}")
1140
1194
 
1141
1195
  agent = _create_deep_agent(**agent_kwargs)
1142
- print(f"[DeepAgent] Agent created, type={type(agent).__name__}", flush=True)
1196
+ print(
1197
+ f"[DeepAgent] Agent created, type={type(agent).__name__}", flush=True)
1143
1198
 
1144
1199
  # 恢复原始 __init__
1145
1200
  if _original_rc_init is not None:
@@ -1182,7 +1237,11 @@ async def _sync_skills_to_sandbox(
1182
1237
  skills_dir: str,
1183
1238
  backend: HTTPSandboxBackend,
1184
1239
  ) -> None:
1185
- """同步 skills 到沙箱(按版本检查,版本不一致则覆盖)"""
1240
+ """同步 skills SKILL.md 到沙箱(按版本检查,版本不一致则覆盖)
1241
+
1242
+ 只同步 SKILL.md 文件,不同步 scripts/ 等大目录。
1243
+ scripts/ 在 agent 实际调用时由 HTTPSandboxBackend._lazy_sync_skill_from_path 按需同步。
1244
+ """
1186
1245
  if not os.path.isdir(skills_dir):
1187
1246
  return
1188
1247
 
@@ -1222,19 +1281,29 @@ async def _sync_skills_to_sandbox(
1222
1281
  pass
1223
1282
 
1224
1283
  if remote_version and remote_version == local_version:
1225
- SYLogger.debug(f"[DeepAgent] 技能版本一致,跳过: {skill_name} v{local_version}")
1284
+ SYLogger.debug(
1285
+ f"[DeepAgent] 技能版本一致,跳过: {skill_name} v{local_version}")
1226
1286
  continue
1227
1287
 
1228
1288
  # 版本不一致或沙箱无版本,删除后重新上传
1229
1289
  try:
1230
1290
  await backend.aexecute(f"rm -rf /skills/{skill_name}")
1231
- SYLogger.info(f"[DeepAgent] 技能版本不一致,覆盖: {skill_name} ({remote_version or '无版本'} → {local_version})")
1291
+ SYLogger.info(
1292
+ f"[DeepAgent] 技能版本不一致,覆盖: {skill_name} ({remote_version or '无版本'} → {local_version})")
1232
1293
  except Exception as e:
1233
- SYLogger.warning(f"[DeepAgent] 删除旧版技能失败: {skill_name}, {e}")
1294
+ SYLogger.warning(
1295
+ f"[DeepAgent] 删除旧版技能失败: {skill_name}, {e}")
1234
1296
  continue
1235
1297
 
1236
- await backend.async_sync_dirs([(src, f"/skills/{skill_name}")])
1237
- SYLogger.info(f"[DeepAgent] 同步技能到沙箱: {skill_name} v{local_version}")
1298
+ # 只同步 SKILL.md(不再同步整个目录)
1299
+ if os.path.isfile(local_skill_md):
1300
+ with open(local_skill_md, "rb") as f:
1301
+ content = f.read()
1302
+ await backend.aupload_files([
1303
+ (f"/skills/{skill_name}/SKILL.md", content)
1304
+ ])
1305
+ SYLogger.info(
1306
+ f"[DeepAgent] 同步技能元数据到沙箱: {skill_name} v{local_version} (仅 SKILL.md)")
1238
1307
  except Exception as e:
1239
1308
  SYLogger.warning(f"[DeepAgent] 技能同步失败: {e}")
1240
1309
 
@@ -1244,16 +1313,14 @@ async def _create_sandbox_backend(
1244
1313
  config: AgentConfig,
1245
1314
  project_root: Path,
1246
1315
  ) -> HTTPSandboxBackend:
1247
- """创建并同步沙箱后端"""
1248
- sync_dirs = []
1316
+ """创建并同步沙箱后端
1249
1317
 
1250
- if config.skills_dir:
1251
- skills_dir = Path(config.skills_dir)
1252
- else:
1253
- skills_dir = project_root / "skills"
1254
- skills_dir.mkdir(parents=True, exist_ok=True)
1255
- sync_dirs.append((str(skills_dir), "/skills"))
1318
+ 注意:skills 目录不再通过 sync_dirs 全量同步,
1319
+ 而是由 _sync_skills_to_sandbox() 做版本感知的增量同步。
1320
+ """
1321
+ sync_dirs = []
1256
1322
 
1323
+ # skills 不再通过 sync_dirs 全量同步,改由 _sync_skills_to_sandbox() 按版本增量同步
1257
1324
  if config.memory_dir:
1258
1325
  memory_dir = Path(config.memory_dir)
1259
1326
  else:
@@ -1261,12 +1328,19 @@ async def _create_sandbox_backend(
1261
1328
  memory_dir.mkdir(parents=True, exist_ok=True)
1262
1329
  sync_dirs.append((str(memory_dir), "/memory"))
1263
1330
 
1331
+ # 确定 skills_dir,传递给沙箱后端用于懒同步
1332
+ if config.skills_dir:
1333
+ skills_dir_for_backend = str(Path(config.skills_dir))
1334
+ else:
1335
+ skills_dir_for_backend = str(project_root / "skills")
1336
+
1264
1337
  sandbox_backend = HTTPSandboxBackend.from_nacos(
1265
1338
  service_name=config.sandbox_service_name,
1266
1339
  user_id=user_id,
1267
1340
  timeout=config.sandbox_timeout,
1268
1341
  sync_dirs=sync_dirs,
1269
1342
  auto_sync=True,
1343
+ skills_dir=skills_dir_for_backend,
1270
1344
  )
1271
1345
 
1272
1346
  try:
@@ -655,16 +655,14 @@ async def _create_sandbox_backend_for_team(
655
655
  config: TeamConfig,
656
656
  project_root: Path,
657
657
  ) -> HTTPSandboxBackend:
658
- """创建并同步沙箱后端(团队版本)"""
659
- sync_dirs = []
658
+ """创建并同步沙箱后端(团队版本)
660
659
 
661
- if config.skills_dir:
662
- skills_dir = Path(config.skills_dir)
663
- else:
664
- skills_dir = project_root / "skills"
665
- skills_dir.mkdir(parents=True, exist_ok=True)
666
- sync_dirs.append((str(skills_dir), "/skills"))
660
+ 注意:skills 目录不再通过 sync_dirs 全量同步,
661
+ 而是由 _sync_skills_to_sandbox() 做版本感知的增量同步。
662
+ """
663
+ sync_dirs = []
667
664
 
665
+ # skills 不再通过 sync_dirs 全量同步,改由 _sync_skills_to_sandbox() 按版本增量同步
668
666
  if config.memory_dir:
669
667
  memory_dir = Path(config.memory_dir)
670
668
  else:
@@ -672,6 +670,12 @@ async def _create_sandbox_backend_for_team(
672
670
  memory_dir.mkdir(parents=True, exist_ok=True)
673
671
  sync_dirs.append((str(memory_dir), "/memory"))
674
672
 
673
+ # 确定 skills_dir,传递给沙箱后端用于懒同步
674
+ if config.skills_dir:
675
+ skills_dir_for_backend = str(Path(config.skills_dir))
676
+ else:
677
+ skills_dir_for_backend = str(project_root / "skills")
678
+
675
679
  sandbox_backend = HTTPSandboxBackend.from_nacos(
676
680
  service_name=config.sandbox_service_name,
677
681
  user_id=user_id,
@@ -679,6 +683,7 @@ async def _create_sandbox_backend_for_team(
679
683
  sync_dirs=sync_dirs,
680
684
  auto_sync=True,
681
685
  version=get_env_var('VERSION') or None,
686
+ skills_dir=skills_dir_for_backend,
682
687
  )
683
688
 
684
689
  # 健康检查
@@ -182,6 +182,7 @@ class FileOperationsMixin:
182
182
  def ls(self: "HTTPSandboxBackend", path: str) -> LsResult:
183
183
  """列出目录内容"""
184
184
  try:
185
+ self._lazy_sync_skill_from_path_sync(path)
185
186
  self._ensure_synced_sync()
186
187
  SYLogger.info(f"[Sandbox] 列出目录: {path}")
187
188
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/ls", {
@@ -198,6 +199,7 @@ class FileOperationsMixin:
198
199
  async def als(self: "HTTPSandboxBackend", path: str, *, timeout: int = None) -> LsResult:
199
200
  """异步列出目录内容"""
200
201
  try:
202
+ await self._lazy_sync_skill_from_path(path)
201
203
  await self._ensure_synced_async()
202
204
  SYLogger.info(f"[Sandbox] 异步列出目录: {path}")
203
205
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/ls", {
@@ -221,6 +223,9 @@ class FileOperationsMixin:
221
223
  ) -> ReadResult:
222
224
  """读取文件内容"""
223
225
  try:
226
+ # 按需懒同步:检测是否读取 skill 资源
227
+ self._lazy_sync_skill_from_path_sync(file_path)
228
+
224
229
  self._ensure_synced_sync()
225
230
  SYLogger.info(
226
231
  f"[Sandbox] 读取文件: {file_path} (offset={offset}, limit={limit})")
@@ -256,6 +261,9 @@ class FileOperationsMixin:
256
261
  ) -> ReadResult:
257
262
  """异步读取文件内容"""
258
263
  try:
264
+ # 按需懒同步:检测是否读取 skill 资源
265
+ await self._lazy_sync_skill_from_path(file_path)
266
+
259
267
  await self._ensure_synced_async()
260
268
  SYLogger.info(
261
269
  f"[Sandbox] 异步读取文件: {file_path} (offset={offset}, limit={limit})")
@@ -302,6 +310,7 @@ class FileOperationsMixin:
302
310
  /conversation_history/ 路径)。设为 False 时,文件已存在返回错误。
303
311
  """
304
312
  try:
313
+ self._lazy_sync_skill_from_path_sync(file_path)
305
314
  self._ensure_synced_sync()
306
315
  SYLogger.info(f"[Sandbox] 写入文件: {file_path} ({len(content)} 字符)")
307
316
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/write", {
@@ -340,6 +349,7 @@ class FileOperationsMixin:
340
349
  timeout: 请求超时时间。
341
350
  """
342
351
  try:
352
+ await self._lazy_sync_skill_from_path(file_path)
343
353
  await self._ensure_synced_async()
344
354
  SYLogger.info(f"[Sandbox] 异步写入文件: {file_path} ({len(content)} 字符)")
345
355
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/write", {
@@ -374,6 +384,7 @@ class FileOperationsMixin:
374
384
  ) -> EditResult:
375
385
  """编辑文件"""
376
386
  try:
387
+ self._lazy_sync_skill_from_path_sync(file_path)
377
388
  self._ensure_synced_sync()
378
389
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/edit", {
379
390
  "file_path": file_path,
@@ -402,6 +413,7 @@ class FileOperationsMixin:
402
413
  ) -> EditResult:
403
414
  """异步编辑文件"""
404
415
  try:
416
+ await self._lazy_sync_skill_from_path(file_path)
405
417
  await self._ensure_synced_async()
406
418
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/edit", {
407
419
  "file_path": file_path,
@@ -424,6 +436,7 @@ class FileOperationsMixin:
424
436
  def glob(self: "HTTPSandboxBackend", pattern: str, path: str | None = None) -> GlobResult:
425
437
  """glob 搜索文件"""
426
438
  try:
439
+ self._lazy_sync_skill_from_path_sync(path or "/")
427
440
  self._ensure_synced_sync()
428
441
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/glob", {
429
442
  "pattern": pattern,
@@ -439,6 +452,7 @@ class FileOperationsMixin:
439
452
  async def aglob(self: "HTTPSandboxBackend", pattern: str, path: str | None = None, *, timeout: int = None) -> GlobResult:
440
453
  """异步 glob 搜索文件"""
441
454
  try:
455
+ await self._lazy_sync_skill_from_path(path or "/")
442
456
  await self._ensure_synced_async()
443
457
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/glob", {
444
458
  "pattern": pattern,
@@ -456,6 +470,7 @@ class FileOperationsMixin:
456
470
  def tree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0) -> TreeResult:
457
471
  """以树形结构展示目录"""
458
472
  try:
473
+ self._lazy_sync_skill_from_path_sync(path)
459
474
  self._ensure_synced_sync()
460
475
  SYLogger.info(f"[Sandbox] tree: {path}")
461
476
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/tree", {
@@ -476,6 +491,7 @@ class FileOperationsMixin:
476
491
  async def atree(self: "HTTPSandboxBackend", path: str = "/", max_depth: int = 30, max_files: int = 0, *, timeout: int = None) -> TreeResult:
477
492
  """异步以树形结构展示目录"""
478
493
  try:
494
+ await self._lazy_sync_skill_from_path(path)
479
495
  await self._ensure_synced_async()
480
496
  SYLogger.info(f"[Sandbox] 异步 tree: {path}")
481
497
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/tree", {
@@ -501,6 +517,7 @@ class FileOperationsMixin:
501
517
  ) -> GrepResult:
502
518
  """搜索文件内容"""
503
519
  try:
520
+ self._lazy_sync_skill_from_path_sync(path or "/")
504
521
  self._ensure_synced_sync()
505
522
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/grep", {
506
523
  "pattern": pattern,
@@ -534,6 +551,7 @@ class FileOperationsMixin:
534
551
  ) -> GrepResult:
535
552
  """异步搜索文件内容"""
536
553
  try:
554
+ await self._lazy_sync_skill_from_path(path or "/")
537
555
  await self._ensure_synced_async()
538
556
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/grep", {
539
557
  "pattern": pattern,
@@ -564,6 +582,7 @@ class FileOperationsMixin:
564
582
  ) -> DeleteResult:
565
583
  """删除文件或文件夹"""
566
584
  try:
585
+ self._lazy_sync_skill_from_path_sync(path)
567
586
  self._ensure_synced_sync()
568
587
  SYLogger.info(f"[Sandbox] 删除: {path} (recursive={recursive})")
569
588
  result = self._post_sync(f"{SANDBOX_API_PREFIX}/delete", {
@@ -593,6 +612,7 @@ class FileOperationsMixin:
593
612
  ) -> DeleteResult:
594
613
  """异步删除文件或文件夹"""
595
614
  try:
615
+ await self._lazy_sync_skill_from_path(path)
596
616
  await self._ensure_synced_async()
597
617
  SYLogger.info(f"[Sandbox] 异步删除: {path} (recursive={recursive})")
598
618
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/delete", {
@@ -618,6 +638,7 @@ class FileOperationsMixin:
618
638
  async def astat(self: "HTTPSandboxBackend", path: str, *, timeout: int = None) -> "StatResult":
619
639
  """异步检查文件或目录状态"""
620
640
  try:
641
+ await self._lazy_sync_skill_from_path(path)
621
642
  await self._ensure_synced_async()
622
643
  SYLogger.info(f"[Sandbox] 异步查询文件状态: {path}")
623
644
  result = await self._post_async_with_failover(f"{SANDBOX_API_PREFIX}/stat", {
@@ -14,7 +14,7 @@ import sys
14
14
  import tempfile
15
15
  import threading
16
16
  import requests
17
- from pathlib import Path
17
+ from pathlib import Path, PurePosixPath
18
18
  from typing import Optional, List
19
19
 
20
20
  import aiohttp
@@ -126,6 +126,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
126
126
  nacos_group: str = "DEFAULT_GROUP",
127
127
  token: str = None,
128
128
  env: dict[str, str] = None,
129
+ skills_dir: str = None,
129
130
  ):
130
131
  """
131
132
  初始化 HTTP 沙箱后端
@@ -139,6 +140,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
139
140
  nacos_service_name: Nacos 服务名(用于故障转移时重新发现服务)
140
141
  nacos_group: Nacos 服务分组
141
142
  env: 注入到沙箱的环境变量字典,每次执行命令时自动前置 export
143
+ skills_dir: 本地 skills 目录路径,用于 execute 时按需懒同步 scripts/
142
144
  """
143
145
  self._base_url = base_url.rstrip("/")
144
146
  self._user_id = user_id.lower().replace(" ", "") if user_id else user_id
@@ -149,6 +151,10 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
149
151
  self._workspace_verified = False
150
152
  self._local_mode: Optional[bool] = None
151
153
 
154
+ # Skills 懒同步:记录本地 skills 目录,访问时按需同步整个 skill 子目录
155
+ self._skills_dir = skills_dir
156
+ self._synced_skills: set[str] = set() # 已完整同步的 skill 名称
157
+
152
158
  # Token authentication: instance-level > env var
153
159
  self._token = token if token is not None else os.environ.get(
154
160
  "SANDBOX_TOKEN", "")
@@ -244,6 +250,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
244
250
  auto_sync: bool = False,
245
251
  load_balance: bool = True,
246
252
  env: dict[str, str] = None,
253
+ skills_dir: str = None,
247
254
  ) -> "HTTPSandboxBackend":
248
255
  """
249
256
  从 Nacos 服务发现创建后端实例
@@ -257,6 +264,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
257
264
  sync_dirs: 要同步的目录列表 [(本地路径, 沙箱路径), ...]
258
265
  auto_sync: 是否在首次文件操作时自动同步
259
266
  load_balance: 是否基于 user_id 进行负载均衡(默认 True)
267
+ skills_dir: 本地 skills 目录路径,用于 execute 时按需懒同步 scripts/
260
268
 
261
269
  Returns:
262
270
  HTTPSandboxBackend 实例
@@ -279,6 +287,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
279
287
  sync_dirs=sync_dirs,
280
288
  auto_sync=auto_sync,
281
289
  env=env,
290
+ skills_dir=skills_dir,
282
291
  )
283
292
 
284
293
  from sycommon.synacos.nacos_service import NacosService
@@ -330,6 +339,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
330
339
  nacos_service_name=service_name,
331
340
  nacos_group=group,
332
341
  env=env,
342
+ skills_dir=skills_dir,
333
343
  )
334
344
 
335
345
  @classmethod
@@ -344,17 +354,22 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
344
354
  auto_sync: bool = False,
345
355
  load_balance: bool = True,
346
356
  env: dict[str, str] = None,
357
+ skills_dir: str = None,
347
358
  ) -> "HTTPSandboxBackend":
348
359
  """异步从 Nacos 服务发现创建后端实例"""
349
360
  return await asyncio.to_thread(
350
361
  cls.from_nacos,
351
- service_name, user_id, group, version, timeout, sync_dirs, auto_sync, load_balance, env,
362
+ service_name, user_id, group, version, timeout, sync_dirs, auto_sync, load_balance, env, skills_dir,
352
363
  )
353
364
 
354
365
  # ============== 内部方法 - 同步版本 ==============
355
366
 
356
367
  def _refresh_from_nacos_and_switch_sync(self) -> bool:
357
- """从 Nacos 重新获取服务实例并切换(同步版本)"""
368
+ """从 Nacos 重新获取服务实例并切换(同步版本)
369
+
370
+ 优先从 MinIO 恢复用户文件到新沙箱,避免沙箱间互相迁移脏数据。
371
+ 只有 MinIO 不可用时才尝试从旧沙箱拉取。
372
+ """
358
373
  # 防止递归:如果在故障转移过程中再次调用,直接返回 False
359
374
  if getattr(self, '_in_failover', False):
360
375
  SYLogger.warning("[Sandbox] 已在故障转移过程中,跳过重复调用")
@@ -399,33 +414,40 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
399
414
  old_url = self._base_url
400
415
  SYLogger.info(f"[Sandbox] 准备切换服务: {old_url} -> {new_url}")
401
416
 
402
- # 1. 先从旧服务下载工作空间文件
403
- workspace_files = []
404
- try:
405
- sync_result = self._sync_workspace_sync()
406
- workspace_files = sync_result.get("files", [])
407
- SYLogger.info(
408
- f"[Sandbox] 从旧服务获取 {len(workspace_files)} 个工作空间文件")
409
- except Exception as e:
410
- SYLogger.warning(f"[Sandbox] 获取工作空间文件失败: {e}")
411
-
412
- # 2. 切换到新服务
417
+ # 1. 切换到新服务
413
418
  self._base_url = new_url
414
419
  self._synced = False
415
420
 
416
- # 3. 同步配置的目录到新服务
421
+ # 2. 同步配置的目录到新服务(skills, memory 等本地文件)
417
422
  if self._sync_dirs:
418
423
  SYLogger.info(f"[Sandbox] 重新同步配置目录到新服务...")
419
424
  self._sync_sync()
420
425
 
421
- # 4. 上传工作空间文件到新服务
422
- if workspace_files:
423
- SYLogger.info(
424
- f"[Sandbox] 上传 {len(workspace_files)} 个工作空间文件到新服务...")
425
- upload_result = self._upload_workspace_files_sync(
426
- workspace_files)
426
+ # 3. 优先从 MinIO 恢复用户工作空间文件
427
+ workspace_restored = False
428
+ try:
429
+ from sycommon.agent.sandbox.minio_sync import MinioSyncService
430
+ minio = MinioSyncService()
431
+ if minio.is_available():
432
+ SYLogger.info(
433
+ f"[Sandbox] 从 MinIO 恢复用户文件到新沙箱...")
434
+ import asyncio
435
+ loop = asyncio.new_event_loop()
436
+ try:
437
+ restored = loop.run_until_complete(
438
+ minio.restore_from_minio(self, self._user_id))
439
+ workspace_restored = restored > 0
440
+ SYLogger.info(
441
+ f"[Sandbox] MinIO 恢复完成: {restored} 个文件")
442
+ finally:
443
+ loop.close()
444
+ except Exception as e:
445
+ SYLogger.warning(
446
+ f"[Sandbox] MinIO 恢复失败: {e}")
447
+
448
+ if not workspace_restored:
427
449
  SYLogger.info(
428
- f"[Sandbox] 工作空间迁移完成: 成功 {upload_result['success']}, 失败 {upload_result['failed']}")
450
+ f"[Sandbox] MinIO 无可用数据,新沙箱将从空工作空间开始")
429
451
 
430
452
  SYLogger.info(f"[Sandbox] 服务切换完成: {old_url} -> {new_url}")
431
453
  return True
@@ -702,9 +724,17 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
702
724
  )
703
725
 
704
726
  async def aexecute(self, command: str, *, timeout: int = None) -> ExecuteResponse:
705
- """异步执行 shell 命令(服务端负责路径隔离和目录初始化)"""
727
+ """异步执行 shell 命令(服务端负责路径隔离和目录初始化)
728
+
729
+ 如果命令引用了 /skills/<name>/ 下的资源,
730
+ 会自动按需同步该 skill 的整个目录到沙箱。
731
+ """
706
732
  command = self._inject_env(command)
707
733
  SYLogger.info(f"[Sandbox] aexecute 开始: command={command}")
734
+
735
+ # 按需懒同步:检测命令中是否引用了 skill 资源
736
+ await self._lazy_sync_skill_from_path(command)
737
+
708
738
  await self._ensure_synced_async()
709
739
  SYLogger.info(f"[Sandbox] _ensure_synced_async 完成,开始执行命令: {command}")
710
740
  try:
@@ -728,6 +758,79 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
728
758
  truncated=False
729
759
  )
730
760
 
761
+ async def _lazy_sync_skill_from_path(self, path: str) -> None:
762
+ """从文件路径中检测是否访问了 skill 资源,按需同步整个 skill 目录
763
+
764
+ 检测路径中是否包含 /skills/<name>/ 模式(不限 scripts/,覆盖
765
+ assets/、references/ 及任意自定义子目录)。
766
+ 如果该 skill 尚未同步,则上传整个 skill 目录到沙箱。
767
+ """
768
+ if not self._skills_dir:
769
+ return
770
+
771
+ import re
772
+ # 匹配 /skills/<skill_name>/ 或 skills/<skill_name>/
773
+ match = re.search(r'/skills/([a-zA-Z0-9_\-]+)/', path)
774
+ if not match:
775
+ match = re.search(r'(?:^|\s|")skills/([a-zA-Z0-9_\-]+)/', path)
776
+ if not match:
777
+ return
778
+
779
+ skill_name = match.group(1)
780
+ if skill_name in self._synced_skills:
781
+ return
782
+
783
+ skill_dir = os.path.join(self._skills_dir, skill_name)
784
+ if not os.path.isdir(skill_dir):
785
+ self._synced_skills.add(skill_name)
786
+ return
787
+
788
+ try:
789
+ await self.async_sync_dirs([
790
+ (skill_dir, f"/skills/{skill_name}")
791
+ ])
792
+ self._synced_skills.add(skill_name)
793
+ SYLogger.info(
794
+ f"[Sandbox] 懒同步技能目录: {skill_name}/")
795
+ except Exception as e:
796
+ SYLogger.warning(
797
+ f"[Sandbox] 懒同步技能目录失败: {skill_name}, {e}")
798
+
799
+ def _lazy_sync_skill_from_path_sync(self, path: str) -> None:
800
+ """同步版本的懒同步(在同步文件操作中调用)
801
+
802
+ 内部使用 asyncio.run_coroutine_threadsafe 或回退到同步上传。
803
+ """
804
+ if not self._skills_dir:
805
+ return
806
+
807
+ import re
808
+ match = re.search(r'/skills/([a-zA-Z0-9_\-]+)/', path)
809
+ if not match:
810
+ match = re.search(r'(?:^|\s|")skills/([a-zA-Z0-9_\-]+)/', path)
811
+ if not match:
812
+ return
813
+
814
+ skill_name = match.group(1)
815
+ if skill_name in self._synced_skills:
816
+ return
817
+
818
+ skill_dir = os.path.join(self._skills_dir, skill_name)
819
+ if not os.path.isdir(skill_dir):
820
+ self._synced_skills.add(skill_name)
821
+ return
822
+
823
+ try:
824
+ # 同步版本直接调用同步上传方法
825
+ self._upload_directory_sync(
826
+ skill_dir, f"/skills/{skill_name}", mode="full")
827
+ self._synced_skills.add(skill_name)
828
+ SYLogger.info(
829
+ f"[Sandbox] 懒同步技能目录(sync): {skill_name}/")
830
+ except Exception as e:
831
+ SYLogger.warning(
832
+ f"[Sandbox] 懒同步技能目录失败(sync): {skill_name}, {e}")
833
+
731
834
  async def _sync_async(self, *, timeout: int = None) -> dict:
732
835
  """同步目录(异步版本)
733
836
 
@@ -855,7 +958,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
855
958
 
856
959
  local_file = Path(root) / file
857
960
  relative_path = local_file.relative_to(local_path)
858
- sandbox_path = str(Path(remote_path) / relative_path)
961
+ sandbox_path = str(PurePosixPath(remote_path) / relative_path.as_posix())
859
962
  result.append((sandbox_path, local_file))
860
963
  return result
861
964
 
@@ -1236,7 +1339,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
1236
1339
 
1237
1340
  local_file = Path(root) / file
1238
1341
  relative_path = local_file.relative_to(local_path)
1239
- sandbox_path = str(Path(remote_path) / relative_path)
1342
+ sandbox_path = str(PurePosixPath(remote_path) / relative_path.as_posix())
1240
1343
  files_to_upload.append((sandbox_path, local_file))
1241
1344
 
1242
1345
  SYLogger.info(f"[Sandbox] 准备上传 {len(files_to_upload)} 个文件到沙箱")
@@ -218,6 +218,19 @@ class MinioSyncService(metaclass=SingletonMeta):
218
218
  """异步删除 MinIO 中指定前缀下的所有对象"""
219
219
  return await asyncio.to_thread(self.remove_prefix, prefix)
220
220
 
221
+ async def cleanup_user_history(self, user_id: str) -> None:
222
+ """清理用户沙箱对话历史和工具缓存(沙箱 + MinIO 双删)
223
+
224
+ 用于新建会话/重置对话时,确保沙箱和 MinIO 中的
225
+ conversation_history 和 large_tool_results 都被清除,
226
+ 避免 restore_from_minio 把旧文件同步回来。
227
+ """
228
+ uid = user_id.lower().replace(" ", "")
229
+ await asyncio.gather(
230
+ self.aremove_prefix(f"{uid}/current/conversation_history/"),
231
+ self.aremove_prefix(f"{uid}/current/large_tool_results/"),
232
+ )
233
+
221
234
  # ============== 查找最近副本 ==============
222
235
 
223
236
  def find_latest_object_key(self, user_id: str, file_path: str) -> Optional[str]:
@@ -303,7 +316,10 @@ class MinioSyncService(metaclass=SingletonMeta):
303
316
  async def sync_sandbox_to_minio(self, sandbox_backend, user_id: str) -> int:
304
317
  """扫描沙箱目录,增量同步文件到 MinIO
305
318
 
306
- 存储到 {user_id}/current/ 目录,只上传新增或修改的文件。
319
+ 存储到 {user_id}/current/ 目录:
320
+ - 上传新增或修改的文件
321
+ - 删除 MinIO 中存在但沙箱中已不存在的文件(双向同步)
322
+
307
323
  max_files=0 表示不限制,tree 返回全部文件。
308
324
  下载/上传采用并发(每批 CONCURRENT_BATCH 个),避免串行太慢。
309
325
 
@@ -329,6 +345,13 @@ class MinioSyncService(metaclass=SingletonMeta):
329
345
 
330
346
  sandbox_files = self.collect_sandbox_files(tree_result)
331
347
  if not sandbox_files:
348
+ # 沙箱为空,清理 MinIO 中该用户的所有文件
349
+ try:
350
+ removed = await self.aremove_prefix(f"{user_id.lower()}/current/")
351
+ if removed > 0:
352
+ SYLogger.info(f"[MinIO] 沙箱为空,清理 MinIO: user={user_id}, removed={removed}")
353
+ except Exception as e:
354
+ SYLogger.warning(f"[MinIO] 沙箱为空时清理 MinIO 失败: {e}")
332
355
  return 0
333
356
 
334
357
  # 2. 确定存储目录
@@ -353,17 +376,36 @@ class MinioSyncService(metaclass=SingletonMeta):
353
376
  SYLogger.warning(f"[MinIO] 列出已有副本失败,将全量上传: {e}")
354
377
 
355
378
  # 4. 找出需要同步的文件(新增的不在 MinIO 中)
379
+ sandbox_file_set = set(sandbox_files)
356
380
  changed_files = [
357
381
  fp for fp in sandbox_files if fp not in existing_objects]
358
382
 
383
+ # 5. 找出需要删除的文件(MinIO 中存在但沙箱中已不存在)
384
+ deleted_files = [
385
+ fp for fp in existing_objects if fp not in sandbox_file_set]
386
+
387
+ # 删除 MinIO 中的孤立文件
388
+ if deleted_files:
389
+ removed_count = 0
390
+ for fp in deleted_files:
391
+ try:
392
+ object_key = f"{prefix}{fp}"
393
+ await self.aremove_object(object_key)
394
+ removed_count += 1
395
+ except Exception as e:
396
+ SYLogger.warning(f"[MinIO] 删除孤立文件失败: {fp}, {e}")
397
+ if removed_count > 0:
398
+ SYLogger.info(
399
+ f"[MinIO] 清理孤立文件: user={user_id}, removed={removed_count}")
400
+
359
401
  if not changed_files:
360
- SYLogger.info(f"[MinIO] 所有文件均无变化,跳过同步: user={user_id}")
402
+ SYLogger.info(f"[MinIO] 所有文件均无变化: user={user_id}")
361
403
  return 0
362
404
 
363
405
  SYLogger.info(
364
406
  f"[MinIO] 检测到 {len(changed_files)} 个文件有变化: user={user_id}")
365
407
 
366
- # 5. 并发下载并上传变化文件
408
+ # 6. 并发下载并上传变化文件
367
409
  synced = await self._sync_batch(sandbox_backend, changed_files, existing_objects, prefix)
368
410
 
369
411
  SYLogger.info(
@@ -258,6 +258,81 @@ class WeComLDAPService(metaclass=SingletonMeta):
258
258
  async with session.get(url, params=params) as resp:
259
259
  return await resp.json()
260
260
 
261
+ async def _wecom_post(self, path: str, json_body: dict = None) -> dict:
262
+ """封装企微 POST 请求"""
263
+ token = await self._get_access_token()
264
+ url = f"https://qyapi.weixin.qq.com{path}?access_token={token}"
265
+
266
+ async with aiohttp.ClientSession() as session:
267
+ async with session.post(url, json=json_body or {}) as resp:
268
+ return await resp.json()
269
+
270
+ async def _decode_open_userid(self, open_userid: str) -> Optional[str]:
271
+ """将企微密文 open_userid 转换为明文 userid
272
+
273
+ 当 userid 长度超过 20 位时,大概率是智能机器人的密文 open_userid,
274
+ 需要通过 batch/openuserid_to_userid 接口解码。
275
+
276
+ Args:
277
+ open_userid: 密文格式的企微用户ID
278
+
279
+ Returns:
280
+ 解码后的明文 userid,失败返回 None
281
+ """
282
+ try:
283
+ data = await self._wecom_post(
284
+ "/cgi-bin/batch/openuserid_to_userid",
285
+ {"open_userid_list": [open_userid]},
286
+ )
287
+ if data.get("errcode") != 0:
288
+ SYLogger.warning(
289
+ f"[WeComLDAP] open_userid 解码失败: {data}"
290
+ )
291
+ return None
292
+
293
+ result_list = data.get("userid_list", [])
294
+ if not result_list:
295
+ SYLogger.warning(
296
+ f"[WeComLDAP] open_userid 解码结果为空: {data}"
297
+ )
298
+ return None
299
+
300
+ item = result_list[0]
301
+ # 单条解码失败时 errcode 非 0
302
+ if item.get("errcode", 0) != 0:
303
+ SYLogger.warning(
304
+ f"[WeComLDAP] open_userid {open_userid} 解码失败: {item}"
305
+ )
306
+ return None
307
+
308
+ decoded = item.get("userid", "")
309
+ if decoded:
310
+ logging.info(
311
+ f"[WeComLDAP] open_userid 解码成功: {open_userid} -> {decoded}"
312
+ )
313
+ return decoded or None
314
+
315
+ except Exception as e:
316
+ SYLogger.error(f"[WeComLDAP] open_userid 解码异常: {e}")
317
+ return None
318
+
319
+ async def _try_decode_userid(self, userid: str) -> str:
320
+ """尝试解码 userid,超过 20 位时自动尝试 open_userid 解码
321
+
322
+ 解码失败则原样返回,不影响后续流程。
323
+
324
+ Args:
325
+ userid: 原始企微 userid
326
+
327
+ Returns:
328
+ 解码后的 userid 或原始 userid
329
+ """
330
+ if len(userid) <= 20:
331
+ return userid
332
+
333
+ decoded = await self._decode_open_userid(userid)
334
+ return decoded if decoded else userid
335
+
261
336
  async def _get_wecom_user(self, userid: str) -> Optional[dict]:
262
337
  """获取单个企微用户详情"""
263
338
  data = await self._wecom_get(
@@ -462,14 +537,20 @@ class WeComLDAPService(metaclass=SingletonMeta):
462
537
  async def get_user_info(self, wecom_userid: str) -> WeComLDAPUser:
463
538
  """通过企微 userid 获取关联的 LDAP 完整用户信息
464
539
 
540
+ 当 userid 超过 20 位时,自动尝试通过 batch/openuserid_to_userid
541
+ 接口将密文 open_userid 解码为明文 userid。
542
+
465
543
  Args:
466
- wecom_userid: 企微用户ID(如 "0633", "SZ0888")
544
+ wecom_userid: 企微用户ID(如 "0633", "SZ0888",或密文 open_userid
467
545
 
468
546
  Returns:
469
547
  WeComLDAPUser: 合并后的用户信息,包含 LDAP 域账号详情
470
548
  """
549
+ # 0. 尝试解码密文 open_userid
550
+ decoded_userid = await self._try_decode_userid(wecom_userid)
551
+
471
552
  # 1. 从企微获取用户信息
472
- user_data = await self._get_wecom_user(wecom_userid)
553
+ user_data = await self._get_wecom_user(decoded_userid)
473
554
  if not user_data:
474
555
  return WeComLDAPUser(wecom_userid=wecom_userid, wecom_name="")
475
556
 
@@ -477,7 +558,10 @@ class WeComLDAPService(metaclass=SingletonMeta):
477
558
  ldap_index = await self._get_ldap_index()
478
559
 
479
560
  # 3. 合并返回
480
- return await self._build_wecom_ldap_user(user_data, ldap_index)
561
+ result = await self._build_wecom_ldap_user(user_data, ldap_index)
562
+ # 保留原始传入的 wecom_userid
563
+ result.wecom_userid = wecom_userid
564
+ return result
481
565
 
482
566
  async def get_all_users(self) -> List[WeComLDAPUser]:
483
567
  """获取全量企微用户并关联 LDAP 信息
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.5a36
3
+ Version: 0.2.5a38
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -129,16 +129,16 @@ sycommon/services.py,sha256=pUcV0xFrn1hrArD9YwE8m6CMrrWPwNBAqUIWhgsVkzY,24943
129
129
  sycommon/agent/__init__.py,sha256=mxceAeUifQ-DKvWp7ZEJIFlmOCb5wpYHPGQw3rwEN8I,4378
130
130
  sycommon/agent/agent_manager.py,sha256=UhhaekEumT7g4v_Z1UB4jTp13X0n8M8erYaQdkGGWkA,13620
131
131
  sycommon/agent/chat_events.py,sha256=t7qWa6OrIWLfqtd1AnqaVP67QWkn1JxpCBTZYQpoLuM,14226
132
- sycommon/agent/deep_agent.py,sha256=gG05Uqh01L56J1DGkuUcGPwSRmQVifw0AdJa0E3xK-g,60787
133
- sycommon/agent/multi_agent_team.py,sha256=UjtJCjq-bHFLvdN6n__Bhb_w-hV8EKBKyTTb7T1n8l8,26385
132
+ sycommon/agent/deep_agent.py,sha256=VusZURT_SKsMjje7rmUNjtS01Ootqr2C_fq5eORXAJY,63333
133
+ sycommon/agent/multi_agent_team.py,sha256=SRCZHswfflU1w38_YaF0pqh20qa5N5xqcGAVCBoZzmM,26675
134
134
  sycommon/agent/summarization_utils.py,sha256=fiwvKYE_eQc1EtBiPT4UyOZoqQdd4JwEsLRDsJNioLQ,14928
135
135
  sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
136
136
  sycommon/agent/mcp/models.py,sha256=zda4ho-UTYOJ5oPAZPZ-vxhfDRLghUCsMroHnA2A2QQ,1490
137
137
  sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBqZBZs,10960
138
138
  sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
139
- sycommon/agent/sandbox/file_ops.py,sha256=JwxGwZzcjUEZ8bvomVe-pjVqneODG4_zl5142g71Qdc,24720
140
- sycommon/agent/sandbox/http_sandbox_backend.py,sha256=_l7guM-sv4XduE9NTNVLPyRTjZZIGR4qefJUTc8e5q4,59199
141
- sycommon/agent/sandbox/minio_sync.py,sha256=d1kuWllvyAvAMsFZCP0OdHEQtXN9BEIgHbupC31BjSk,20000
139
+ sycommon/agent/sandbox/file_ops.py,sha256=sqNxlrrzvgJPpHqJB98Us-jsKOYLjal4YUA2Ej7abeU,25852
140
+ sycommon/agent/sandbox/http_sandbox_backend.py,sha256=pBCtNXbuaDn_p_UFaOPKTAGzjcRYhpogbxbiQoOMxG4,63255
141
+ sycommon/agent/sandbox/minio_sync.py,sha256=2vc0uw4IVER9F2W-dja1wbZwEy4G8UVTz4kDaBDzWBE,21919
142
142
  sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
143
143
  sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
144
144
  sycommon/agent/sandbox/session.py,sha256=TjzC3yFC-VaJ75UwCyL26QX4PRTGNNfQae1FKFuOsYI,2365
@@ -147,7 +147,7 @@ sycommon/auth/ldap_service.py,sha256=fOcpVov5LWJkBk62qbTaltks1c4la7JsbD104KfdBOI
147
147
  sycommon/auth/oa_cache.py,sha256=u673y-mK-xo24pSqvfjL68_YFASO2zoxd_iAQ0HetCo,3491
148
148
  sycommon/auth/oa_crypto.py,sha256=xpY1R1Bj3KLENXB0TuThB6Eku1E9PYjcoSpOdgDmCgc,1898
149
149
  sycommon/auth/oa_service.py,sha256=kLepV9zgqpZoaB73DRPpMA5tJJQjoaDtQPdzBcGXeak,6235
150
- sycommon/auth/wecom_ldap_service.py,sha256=v8CvPTafvM1qiGHzO48eiD5TjQZa2qAk3OoOG3sMZgY,19799
150
+ sycommon/auth/wecom_ldap_service.py,sha256=CIrGcAr2yD1VTTT3pxzjLCGcH2SxrZrjWM8WkCrx7nk,22829
151
151
  sycommon/config/Config.py,sha256=J5VjolqHmhYTBZwTyfSHv9X5cHMfN6kqY2ryHF4LJPw,6785
152
152
  sycommon/config/DatabaseConfig.py,sha256=ILiUuYT9_xJZE2W-RYuC3JCt_YLKc1sbH13-MHIOPhg,804
153
153
  sycommon/config/ElasticsearchConfig.py,sha256=fO9ZPMgJxSg1-UyDJ90wO6UvYy-jscwPJsSkXgx9qTU,2308
@@ -282,8 +282,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
282
282
  sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
283
283
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
284
284
  sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
285
- sycommon_python_lib-0.2.5a36.dist-info/METADATA,sha256=BL0G4BxFGVbhaxumOuqyQOMPCunq2-NcEu0SOxfanKk,7914
286
- sycommon_python_lib-0.2.5a36.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
287
- sycommon_python_lib-0.2.5a36.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
288
- sycommon_python_lib-0.2.5a36.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
289
- sycommon_python_lib-0.2.5a36.dist-info/RECORD,,
285
+ sycommon_python_lib-0.2.5a38.dist-info/METADATA,sha256=ZpqFN3mks3IrAOrr8_85SIT8YLXH8JjT9ukEerUL3bU,7914
286
+ sycommon_python_lib-0.2.5a38.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
287
+ sycommon_python_lib-0.2.5a38.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
288
+ sycommon_python_lib-0.2.5a38.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
289
+ sycommon_python_lib-0.2.5a38.dist-info/RECORD,,