langchain-agentx-python 2.4.5__py3-none-any.whl → 2.4.7__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.
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "2.4.5"
14
+ __version__ = "2.4.7"
15
15
 
16
16
  from .mcp.call import McpToolCaller
17
17
  from .mcp.client_manager import McpClientManager, McpConnectedClient
@@ -19,7 +19,8 @@ Fork 路径区分(对齐 CC):
19
19
  ``path_prep`` 与遗留 ``cwd``/worktree 自建二选一(编排层须传 ``path_prep``);
20
20
  fork + worktree 时可选注入 CC 等价 ``buildWorktreeNotice`` user 消息(见 ``fork_worktree_notice``);
21
21
  create_subagent_context 在 fork_from_parent=True 时注入 fork-boilerplate 行为规范;
22
- P0-1: 子代理默认隔离 session_store(父为 AgentSessionStore 时使用 clone(file_state_only=True, inherit_grants=True))。
22
+ P0-1: 子代理隔离 session_store(clone + inherit_grants);读缓存仅在
23
+ fork_from_parent=True 时继承(对齐 CC forkContextMessages ⟺ cloneFileStateCache)。
23
24
  """
24
25
 
25
26
  import os
@@ -287,11 +288,15 @@ def create_subagent_context(
287
288
 
288
289
  parent_store = getattr(parent_ctx, "session_store", None)
289
290
  session_store: Any = parent_store
290
- # P0-1 修复:子代理默认隔离 session_store
291
- # 无论 plain 还是 fork 模式,子代理都应使用 clone(file_state_only=True, inherit_grants=True)
292
- # 来继承文件读状态 + spawn 时刻 grant 快照,但隔离 cwd_override、权限缓存等运行态
291
+ # P0-1:子代理隔离 session_store(cwd / 权限缓存不共享);grant 快照可继承。
292
+ # 读缓存绑定消息历史(对齐 CC runAgent.ts):仅 fork_from_parent=True 时
293
+ # clone _file_read_state;plain / Skill fork 空缓存,避免 file_unchanged 误判。
293
294
  if isinstance(parent_store, AgentSessionStore):
294
- session_store = parent_store.clone(file_state_only=True, inherit_grants=True)
295
+ session_store = parent_store.clone(
296
+ file_state_only=True,
297
+ inherit_grants=True,
298
+ inherit_file_reads=fork_from_parent,
299
+ )
295
300
 
296
301
  # 写入 cwd 到子会话 session_store.cwd_override(runtime state SSOT)
297
302
  # 这确保后续 LangChainAdapter.build_tool_execution_context() 和
@@ -390,11 +390,19 @@ class AgentSessionStore:
390
390
  *,
391
391
  file_state_only: bool = True,
392
392
  inherit_grants: bool = False,
393
+ inherit_file_reads: bool = True,
393
394
  ) -> AgentSessionStore:
395
+ """克隆会话存储。
396
+
397
+ ``inherit_file_reads`` 对齐 CC ``forkContextMessages !== undefined``
398
+ → ``cloneFileStateCache``;plain / Skill fork 须传 ``False``(空读缓存),
399
+ 避免 Read dedup 返回 ``file_unchanged`` 而子会话无父消息历史。
400
+ """
394
401
  new = AgentSessionStore(session_id=str(uuid.uuid4()))
395
- new._file_read_state = {
396
- k: copy.deepcopy(v) for k, v in self._file_read_state.items()
397
- }
402
+ if inherit_file_reads:
403
+ new._file_read_state = {
404
+ k: copy.deepcopy(v) for k, v in self._file_read_state.items()
405
+ }
398
406
  if inherit_grants:
399
407
  new._granted_roots = copy.deepcopy(self._granted_roots)
400
408
  new.enable_dynamic_root_grant = self.enable_dynamic_root_grant
@@ -42,6 +42,7 @@ from dataclasses import dataclass, field
42
42
  from pathlib import Path
43
43
  from typing import Any, Protocol
44
44
 
45
+ from .bash_exec_assembly import AssembledShellCommand, BashExecAssembler
45
46
  from .cwd_reporter import (
46
47
  BashCwdReporter,
47
48
  bash_single_quoted,
@@ -52,8 +53,6 @@ from .cwd_reporter import (
52
53
  from .sandbox_decision import BashSandboxDecision
53
54
  from .shell_locator import resolve_posix_shell
54
55
  from .task_runtime import BashTaskSnapshot, BashTaskStateMachine
55
- from .shell_quoting import append_stdin_redirect
56
- from .windows_shell_quoting import rewrite_windows_null_redirect
57
56
 
58
57
  # cwd 提取用的特殊 sentinel(不太可能出现在正常输出中)
59
58
  _CWD_SENTINEL = "__BASH_CWD_SENTINEL_63f8a2__"
@@ -159,14 +158,16 @@ def _cwd_invalid_execute_result(cwd: str | None, exc: BaseException) -> ExecuteR
159
158
  )
160
159
 
161
160
 
162
- def _merge_reset_into_result(
163
- result: ExecuteResult, reset_stderr_prefix: str
161
+ def _copy_execute_result(
162
+ result: ExecuteResult,
163
+ *,
164
+ stderr: str | None = None,
165
+ cwd_warning: str | None = None,
164
166
  ) -> ExecuteResult:
165
- if not reset_stderr_prefix:
166
- return result
167
+ """复制 ExecuteResult,保留 D2/D4 装配观测字段。"""
167
168
  return ExecuteResult(
168
169
  stdout=result.stdout,
169
- stderr=reset_stderr_prefix + (result.stderr or ""),
170
+ stderr=result.stderr if stderr is None else stderr,
170
171
  exit_code=result.exit_code,
171
172
  interrupted=result.interrupted,
172
173
  cwd_after=result.cwd_after,
@@ -174,7 +175,22 @@ def _merge_reset_into_result(
174
175
  sandbox_bypass_reason=result.sandbox_bypass_reason,
175
176
  sandbox_temp_dir=result.sandbox_temp_dir,
176
177
  sandbox_violation_message=result.sandbox_violation_message,
177
- cwd_warning=result.cwd_warning,
178
+ cwd_warning=result.cwd_warning if cwd_warning is None else cwd_warning,
179
+ command_before=result.command_before,
180
+ command_after=result.command_after,
181
+ shell_snapshot_path=result.shell_snapshot_path,
182
+ shell_login_flag=result.shell_login_flag,
183
+ )
184
+
185
+
186
+ def _merge_reset_into_result(
187
+ result: ExecuteResult, reset_stderr_prefix: str
188
+ ) -> ExecuteResult:
189
+ if not reset_stderr_prefix:
190
+ return result
191
+ return _copy_execute_result(
192
+ result,
193
+ stderr=reset_stderr_prefix + (result.stderr or ""),
178
194
  )
179
195
 
180
196
 
@@ -187,16 +203,9 @@ def _post_process_execute_result(result: ExecuteResult) -> ExecuteResult:
187
203
  cleaned_stderr, cwd_warning = extract_cwd_warning(result.stderr)
188
204
  # 替换 stderr 为清理后的版本(UI 层看到干净的 stderr)
189
205
  # 同时设置 cwd_warning 字段(UI 层可直接使用)
190
- return ExecuteResult(
191
- stdout=result.stdout,
206
+ return _copy_execute_result(
207
+ result,
192
208
  stderr=cleaned_stderr,
193
- exit_code=result.exit_code,
194
- interrupted=result.interrupted,
195
- cwd_after=result.cwd_after,
196
- sandboxed=result.sandboxed,
197
- sandbox_bypass_reason=result.sandbox_bypass_reason,
198
- sandbox_temp_dir=result.sandbox_temp_dir,
199
- sandbox_violation_message=result.sandbox_violation_message,
200
209
  cwd_warning=cwd_warning,
201
210
  )
202
211
 
@@ -235,6 +244,18 @@ class ExecuteResult:
235
244
  cwd_warning: str | None = None
236
245
  """从 stderr 中提取的 cwd 重置警告信息(结构化)。"""
237
246
 
247
+ command_before: str | None = None
248
+ """进入 preprocess 前的原始命令(D4 可观测)。"""
249
+
250
+ command_after: str | None = None
251
+ """preprocess + snapshot/extglob 装配后的命令串(cwd wrap 前)。"""
252
+
253
+ shell_snapshot_path: str | None = None
254
+ """本次使用的 shell 快照路径;None 表示回退 ``-l``。"""
255
+
256
+ shell_login_flag: bool = False
257
+ """spawn 是否带 ``-l``(无可用快照时为 True,对齐 CC getSpawnArgs)。"""
258
+
238
259
 
239
260
  @dataclass
240
261
  class BackgroundTask:
@@ -581,6 +602,10 @@ class BashBackend:
581
602
  sandbox_backend: BashSandboxBackendProtocol | None = None,
582
603
  sandbox_runtime_config: BashSandboxRuntimeConfig | None = None,
583
604
  posix_shell_executable: str | None = None,
605
+ *,
606
+ enable_shell_snapshot: bool = True,
607
+ shell_snapshot_path: str | None = None,
608
+ exec_assembler: BashExecAssembler | None = None,
584
609
  **_: Any,
585
610
  ) -> None:
586
611
  if sandbox_backend is not None:
@@ -592,6 +617,10 @@ class BashBackend:
592
617
  config=sandbox_runtime_config,
593
618
  )
594
619
  self._posix_shell_override = posix_shell_executable
620
+ self._exec_assembler = exec_assembler or BashExecAssembler(
621
+ enable_snapshot=enable_shell_snapshot,
622
+ injected_snapshot_path=shell_snapshot_path,
623
+ )
595
624
 
596
625
  def _posix_shell_executable(self, env: dict[str, str]) -> str:
597
626
  """解析 argv[0];测试可注入 `posix_shell_executable` 跳过 PATH 探测。"""
@@ -603,10 +632,27 @@ class BashBackend:
603
632
  merged.update(env)
604
633
  return resolve_posix_shell(environ=merged)
605
634
 
635
+ def _assemble_shell_command(
636
+ self, command: str, *, shell_exe: str
637
+ ) -> AssembledShellCommand:
638
+ """D2/D4:preprocess + snapshot/extglob 装配(对齐 CC bashProvider)。"""
639
+ return self._exec_assembler.assemble(command, shell_exe=shell_exe)
640
+
606
641
  def _preprocess_shell_command(self, command: str) -> str:
607
- """进入 `-c` 包装前:win32 nul 纠偏 + stdin EOF(防交互阻塞,对齐 CC)。"""
608
- command = rewrite_windows_null_redirect(command)
609
- return append_stdin_redirect(command)
642
+ """兼容旧调用:仅返回 preprocess 后文本(不含 snapshot 链)。"""
643
+ from .bash_exec_assembly import preprocess_shell_command
644
+
645
+ return preprocess_shell_command(command).after
646
+
647
+ @staticmethod
648
+ def _attach_assembly_meta(
649
+ result: ExecuteResult, assembled: AssembledShellCommand
650
+ ) -> ExecuteResult:
651
+ result.command_before = assembled.preprocess.before
652
+ result.command_after = assembled.command_string
653
+ result.shell_snapshot_path = assembled.snapshot_path
654
+ result.shell_login_flag = assembled.use_login_shell
655
+ return result
610
656
 
611
657
  @staticmethod
612
658
  def _spawn_env_with_shell(env: dict[str, str], shell_exe: str) -> dict[str, str]:
@@ -727,7 +773,8 @@ class BashBackend:
727
773
  import subprocess as _sp
728
774
 
729
775
  shell_exe = self._posix_shell_executable(env)
730
- command = self._preprocess_shell_command(command)
776
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
777
+ command = assembled.command_string
731
778
  effective_cwd, reset_prefix = _prepare_spawn_cwd(cwd, fallback=fallback_cwd)
732
779
  if effective_cwd is None:
733
780
  msg = reset_prefix.strip() or "Shell cwd invalid and no valid fallback"
@@ -737,6 +784,10 @@ class BashBackend:
737
784
  interrupted=False,
738
785
  stderr=msg + "\n",
739
786
  cwd_warning=msg,
787
+ command_before=assembled.preprocess.before,
788
+ command_after=assembled.command_string,
789
+ shell_snapshot_path=assembled.snapshot_path,
790
+ shell_login_flag=assembled.use_login_shell,
740
791
  )
741
792
  read_fd = -1
742
793
  write_fd = -1
@@ -748,14 +799,16 @@ class BashBackend:
748
799
  timeout_sec=timeout_sec,
749
800
  env=env,
750
801
  shell_exe=shell_exe,
802
+ assembled=assembled,
751
803
  )
752
804
  return _merge_reset_into_result(result, reset_prefix)
753
805
 
754
806
  read_fd, write_fd = os.pipe()
755
807
  wrapped = BashCwdReporter.wrap_with_fd3(command, write_fd)
808
+ argv = assembled.spawn_argv(shell_exe, wrapped)
756
809
 
757
810
  proc = _sp.Popen(
758
- [shell_exe, "-c", wrapped],
811
+ argv,
759
812
  stdin=_sp.DEVNULL,
760
813
  stdout=_sp.PIPE,
761
814
  stderr=_sp.PIPE,
@@ -775,15 +828,18 @@ class BashBackend:
775
828
  os.close(read_fd)
776
829
  partial = stdout_bytes.decode("utf-8", errors="replace")
777
830
  partial_stderr = stderr_bytes.decode("utf-8", errors="replace")
778
- return _merge_reset_into_result(
779
- ExecuteResult(
780
- stdout=partial,
781
- stderr=partial_stderr,
782
- exit_code=-1,
783
- interrupted=True,
784
- cwd_after=cwd_after,
831
+ return self._attach_assembly_meta(
832
+ _merge_reset_into_result(
833
+ ExecuteResult(
834
+ stdout=partial,
835
+ stderr=partial_stderr,
836
+ exit_code=-1,
837
+ interrupted=True,
838
+ cwd_after=cwd_after,
839
+ ),
840
+ reset_prefix,
785
841
  ),
786
- reset_prefix,
842
+ assembled,
787
843
  )
788
844
 
789
845
  cwd_after = _read_fd_safe(read_fd)
@@ -792,21 +848,27 @@ class BashBackend:
792
848
 
793
849
  stdout_str = stdout_bytes.decode("utf-8", errors="replace")
794
850
  stderr_str = stderr_bytes.decode("utf-8", errors="replace")
795
- return _merge_reset_into_result(
796
- ExecuteResult(
797
- stdout=stdout_str,
798
- stderr=stderr_str,
799
- exit_code=proc.returncode,
800
- interrupted=False,
801
- cwd_after=cwd_after,
851
+ return self._attach_assembly_meta(
852
+ _merge_reset_into_result(
853
+ ExecuteResult(
854
+ stdout=stdout_str,
855
+ stderr=stderr_str,
856
+ exit_code=proc.returncode,
857
+ interrupted=False,
858
+ cwd_after=cwd_after,
859
+ ),
860
+ reset_prefix,
802
861
  ),
803
- reset_prefix,
862
+ assembled,
804
863
  )
805
864
  except FileNotFoundError:
806
- return ExecuteResult(
807
- stdout="bash: command not found",
808
- exit_code=127,
809
- interrupted=False,
865
+ return self._attach_assembly_meta(
866
+ ExecuteResult(
867
+ stdout="bash: command not found",
868
+ exit_code=127,
869
+ interrupted=False,
870
+ ),
871
+ assembled,
810
872
  )
811
873
  except OSError as exc:
812
874
  if _is_cwd_spawn_oserror(exc):
@@ -832,6 +894,7 @@ class BashBackend:
832
894
  timeout_sec: int,
833
895
  env: dict[str, str],
834
896
  shell_exe: str,
897
+ assembled: AssembledShellCommand | None = None,
835
898
  ) -> ExecuteResult:
836
899
  fd, cwd_file = tempfile.mkstemp(prefix="agentx_bash_cwd_", suffix=".txt")
837
900
  os.close(fd)
@@ -840,11 +903,14 @@ class BashBackend:
840
903
  command, bash_single_quoted(posix_tgt)
841
904
  )
842
905
  spawn_env = self._spawn_env_with_shell(env, shell_exe)
906
+ if assembled is None:
907
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
908
+ argv = assembled.spawn_argv(shell_exe, wrapped)
843
909
  _cf = self._win32_creationflags()
844
910
  try:
845
911
  if _cf:
846
912
  proc = subprocess.Popen(
847
- [shell_exe, "-c", wrapped],
913
+ argv,
848
914
  stdin=subprocess.DEVNULL,
849
915
  stdout=subprocess.PIPE,
850
916
  stderr=subprocess.PIPE,
@@ -854,7 +920,7 @@ class BashBackend:
854
920
  )
855
921
  else:
856
922
  proc = subprocess.Popen(
857
- [shell_exe, "-c", wrapped],
923
+ argv,
858
924
  stdin=subprocess.DEVNULL,
859
925
  stdout=subprocess.PIPE,
860
926
  stderr=subprocess.PIPE,
@@ -869,34 +935,46 @@ class BashBackend:
869
935
  cwd_after = read_cwd_file(cwd_file)
870
936
  partial = stdout_bytes.decode("utf-8", errors="replace")
871
937
  partial_stderr = stderr_bytes.decode("utf-8", errors="replace")
872
- return ExecuteResult(
873
- stdout=partial,
874
- stderr=partial_stderr,
875
- exit_code=-1,
876
- interrupted=True,
877
- cwd_after=cwd_after,
938
+ return self._attach_assembly_meta(
939
+ ExecuteResult(
940
+ stdout=partial,
941
+ stderr=partial_stderr,
942
+ exit_code=-1,
943
+ interrupted=True,
944
+ cwd_after=cwd_after,
945
+ ),
946
+ assembled,
878
947
  )
879
948
 
880
949
  cwd_after = read_cwd_file(cwd_file)
881
950
  stdout_str = stdout_bytes.decode("utf-8", errors="replace")
882
951
  stderr_str = stderr_bytes.decode("utf-8", errors="replace")
883
- return ExecuteResult(
884
- stdout=stdout_str,
885
- stderr=stderr_str,
886
- exit_code=proc.returncode,
887
- interrupted=False,
888
- cwd_after=cwd_after,
952
+ return self._attach_assembly_meta(
953
+ ExecuteResult(
954
+ stdout=stdout_str,
955
+ stderr=stderr_str,
956
+ exit_code=proc.returncode,
957
+ interrupted=False,
958
+ cwd_after=cwd_after,
959
+ ),
960
+ assembled,
889
961
  )
890
962
  except FileNotFoundError:
891
- return ExecuteResult(
892
- stdout="bash: command not found",
893
- stderr="",
894
- exit_code=127,
895
- interrupted=False,
963
+ return self._attach_assembly_meta(
964
+ ExecuteResult(
965
+ stdout="bash: command not found",
966
+ stderr="",
967
+ exit_code=127,
968
+ interrupted=False,
969
+ ),
970
+ assembled,
896
971
  )
897
972
  except OSError as exc:
898
973
  if _is_cwd_spawn_oserror(exc):
899
- return _cwd_invalid_execute_result(cwd, exc)
974
+ return self._attach_assembly_meta(
975
+ _cwd_invalid_execute_result(cwd, exc),
976
+ assembled,
977
+ )
900
978
  raise
901
979
  finally:
902
980
  try:
@@ -973,7 +1051,8 @@ class BashBackend:
973
1051
  fallback_cwd: str | None = None,
974
1052
  ) -> ExecuteResult:
975
1053
  shell_exe = self._posix_shell_executable(env)
976
- command = self._preprocess_shell_command(command)
1054
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
1055
+ command = assembled.command_string
977
1056
  effective_cwd, reset_prefix = _prepare_spawn_cwd(cwd, fallback=fallback_cwd)
978
1057
  if effective_cwd is None:
979
1058
  msg = reset_prefix.strip() or "Shell cwd invalid and no valid fallback"
@@ -983,6 +1062,10 @@ class BashBackend:
983
1062
  interrupted=False,
984
1063
  stderr=msg + "\n",
985
1064
  cwd_warning=msg,
1065
+ command_before=assembled.preprocess.before,
1066
+ command_after=assembled.command_string,
1067
+ shell_snapshot_path=assembled.snapshot_path,
1068
+ shell_login_flag=assembled.use_login_shell,
986
1069
  )
987
1070
  read_fd = -1
988
1071
  write_fd = -1
@@ -994,16 +1077,16 @@ class BashBackend:
994
1077
  timeout_sec=timeout_sec,
995
1078
  env=env,
996
1079
  shell_exe=shell_exe,
1080
+ assembled=assembled,
997
1081
  )
998
1082
  return _merge_reset_into_result(result, reset_prefix)
999
1083
 
1000
1084
  read_fd, write_fd = os.pipe()
1001
1085
  wrapped = BashCwdReporter.wrap_with_fd3(command, write_fd)
1086
+ argv = assembled.spawn_argv(shell_exe, wrapped)
1002
1087
 
1003
1088
  proc = await asyncio.create_subprocess_exec(
1004
- shell_exe,
1005
- "-c",
1006
- wrapped,
1089
+ *argv,
1007
1090
  stdin=asyncio.subprocess.DEVNULL,
1008
1091
  stdout=asyncio.subprocess.PIPE,
1009
1092
  stderr=asyncio.subprocess.PIPE,
@@ -1025,15 +1108,18 @@ class BashBackend:
1025
1108
  cwd_after = _read_fd_safe(read_fd)
1026
1109
  os.close(read_fd)
1027
1110
  read_fd = -1
1028
- return _merge_reset_into_result(
1029
- ExecuteResult(
1030
- stdout="",
1031
- stderr="",
1032
- exit_code=-1,
1033
- interrupted=True,
1034
- cwd_after=cwd_after,
1111
+ return self._attach_assembly_meta(
1112
+ _merge_reset_into_result(
1113
+ ExecuteResult(
1114
+ stdout="",
1115
+ stderr="",
1116
+ exit_code=-1,
1117
+ interrupted=True,
1118
+ cwd_after=cwd_after,
1119
+ ),
1120
+ reset_prefix,
1035
1121
  ),
1036
- reset_prefix,
1122
+ assembled,
1037
1123
  )
1038
1124
 
1039
1125
  cwd_after = _read_fd_safe(read_fd)
@@ -1042,26 +1128,35 @@ class BashBackend:
1042
1128
 
1043
1129
  stdout_str = (stdout_bytes or b"").decode("utf-8", errors="replace")
1044
1130
  stderr_str = (stderr_bytes or b"").decode("utf-8", errors="replace")
1045
- return _merge_reset_into_result(
1046
- ExecuteResult(
1047
- stdout=stdout_str,
1048
- stderr=stderr_str,
1049
- exit_code=proc.returncode or 0,
1050
- interrupted=False,
1051
- cwd_after=cwd_after,
1131
+ return self._attach_assembly_meta(
1132
+ _merge_reset_into_result(
1133
+ ExecuteResult(
1134
+ stdout=stdout_str,
1135
+ stderr=stderr_str,
1136
+ exit_code=proc.returncode or 0,
1137
+ interrupted=False,
1138
+ cwd_after=cwd_after,
1139
+ ),
1140
+ reset_prefix,
1052
1141
  ),
1053
- reset_prefix,
1142
+ assembled,
1054
1143
  )
1055
1144
  except FileNotFoundError:
1056
- return ExecuteResult(
1057
- stdout="bash: command not found",
1058
- stderr="",
1059
- exit_code=127,
1060
- interrupted=False,
1145
+ return self._attach_assembly_meta(
1146
+ ExecuteResult(
1147
+ stdout="bash: command not found",
1148
+ stderr="",
1149
+ exit_code=127,
1150
+ interrupted=False,
1151
+ ),
1152
+ assembled,
1061
1153
  )
1062
1154
  except OSError as exc:
1063
1155
  if _is_cwd_spawn_oserror(exc):
1064
- return _cwd_invalid_execute_result(effective_cwd or cwd, exc)
1156
+ return self._attach_assembly_meta(
1157
+ _cwd_invalid_execute_result(effective_cwd or cwd, exc),
1158
+ assembled,
1159
+ )
1065
1160
  raise
1066
1161
  finally:
1067
1162
  if write_fd != -1:
@@ -1083,6 +1178,7 @@ class BashBackend:
1083
1178
  timeout_sec: int,
1084
1179
  env: dict[str, str],
1085
1180
  shell_exe: str,
1181
+ assembled: AssembledShellCommand | None = None,
1086
1182
  ) -> ExecuteResult:
1087
1183
  fd, cwd_file = tempfile.mkstemp(prefix="agentx_bash_cwd_", suffix=".txt")
1088
1184
  os.close(fd)
@@ -1091,13 +1187,14 @@ class BashBackend:
1091
1187
  command, bash_single_quoted(posix_tgt)
1092
1188
  )
1093
1189
  spawn_env = self._spawn_env_with_shell(env, shell_exe)
1190
+ if assembled is None:
1191
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
1192
+ argv = assembled.spawn_argv(shell_exe, wrapped)
1094
1193
  _cf = self._win32_creationflags()
1095
1194
  try:
1096
1195
  if _cf:
1097
1196
  proc = await asyncio.create_subprocess_exec(
1098
- shell_exe,
1099
- "-c",
1100
- wrapped,
1197
+ *argv,
1101
1198
  stdin=asyncio.subprocess.DEVNULL,
1102
1199
  stdout=asyncio.subprocess.PIPE,
1103
1200
  stderr=asyncio.subprocess.PIPE,
@@ -1107,9 +1204,7 @@ class BashBackend:
1107
1204
  )
1108
1205
  else:
1109
1206
  proc = await asyncio.create_subprocess_exec(
1110
- shell_exe,
1111
- "-c",
1112
- wrapped,
1207
+ *argv,
1113
1208
  stdin=asyncio.subprocess.DEVNULL,
1114
1209
  stdout=asyncio.subprocess.PIPE,
1115
1210
  stderr=asyncio.subprocess.PIPE,
@@ -1125,30 +1220,39 @@ class BashBackend:
1125
1220
  proc.kill()
1126
1221
  await proc.wait()
1127
1222
  cwd_after = read_cwd_file(cwd_file)
1128
- return ExecuteResult(
1129
- stdout="",
1130
- stderr="",
1131
- exit_code=-1,
1132
- interrupted=True,
1133
- cwd_after=cwd_after,
1223
+ return self._attach_assembly_meta(
1224
+ ExecuteResult(
1225
+ stdout="",
1226
+ stderr="",
1227
+ exit_code=-1,
1228
+ interrupted=True,
1229
+ cwd_after=cwd_after,
1230
+ ),
1231
+ assembled,
1134
1232
  )
1135
1233
 
1136
1234
  cwd_after = read_cwd_file(cwd_file)
1137
1235
  stdout_str = (stdout_bytes or b"").decode("utf-8", errors="replace")
1138
1236
  stderr_str = (stderr_bytes or b"").decode("utf-8", errors="replace")
1139
- return ExecuteResult(
1140
- stdout=stdout_str,
1141
- stderr=stderr_str,
1142
- exit_code=proc.returncode or 0,
1143
- interrupted=False,
1144
- cwd_after=cwd_after,
1237
+ return self._attach_assembly_meta(
1238
+ ExecuteResult(
1239
+ stdout=stdout_str,
1240
+ stderr=stderr_str,
1241
+ exit_code=proc.returncode or 0,
1242
+ interrupted=False,
1243
+ cwd_after=cwd_after,
1244
+ ),
1245
+ assembled,
1145
1246
  )
1146
1247
  except FileNotFoundError:
1147
- return ExecuteResult(
1148
- stdout="bash: command not found",
1149
- stderr="",
1150
- exit_code=127,
1151
- interrupted=False,
1248
+ return self._attach_assembly_meta(
1249
+ ExecuteResult(
1250
+ stdout="bash: command not found",
1251
+ stderr="",
1252
+ exit_code=127,
1253
+ interrupted=False,
1254
+ ),
1255
+ assembled,
1152
1256
  )
1153
1257
  except OSError as exc:
1154
1258
  if _is_cwd_spawn_oserror(exc):
@@ -1281,7 +1385,9 @@ class BashBackend:
1281
1385
  )
1282
1386
  return
1283
1387
  stream_env = self._sandbox_stream_env(request)
1284
- wrapped = self._build_stream_wrapper(self._preprocess_shell_command(command))
1388
+ shell_exe = self._posix_shell_executable(stream_env)
1389
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
1390
+ wrapped = self._build_stream_wrapper(assembled.command_string)
1285
1391
  yield from self._stream_execute_raw_iter(
1286
1392
  wrapped_command=wrapped,
1287
1393
  cwd=effective_cwd,
@@ -1289,10 +1395,13 @@ class BashBackend:
1289
1395
  env=stream_env,
1290
1396
  cancel_event=cancel_event,
1291
1397
  fallback_cwd=fallback_cwd,
1398
+ assembled=assembled,
1292
1399
  )
1293
1400
  return
1294
1401
 
1295
- wrapped = self._build_stream_wrapper(self._preprocess_shell_command(command))
1402
+ shell_exe = self._posix_shell_executable(effective_env)
1403
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
1404
+ wrapped = self._build_stream_wrapper(assembled.command_string)
1296
1405
  yield from self._stream_execute_raw_iter(
1297
1406
  wrapped_command=wrapped,
1298
1407
  cwd=cwd,
@@ -1300,6 +1409,7 @@ class BashBackend:
1300
1409
  env=effective_env,
1301
1410
  cancel_event=cancel_event,
1302
1411
  fallback_cwd=fallback_cwd,
1412
+ assembled=assembled,
1303
1413
  )
1304
1414
 
1305
1415
  @staticmethod
@@ -1325,10 +1435,16 @@ class BashBackend:
1325
1435
  env: dict[str, str],
1326
1436
  cancel_event: threading.Event | None,
1327
1437
  fallback_cwd: str | None = None,
1438
+ assembled: AssembledShellCommand | None = None,
1328
1439
  ) -> Iterator[BashStreamEvent]:
1329
1440
  marker = "__AGENTX_STREAM_CWD__"
1330
1441
  shell_exe = self._posix_shell_executable(env)
1331
1442
  spawn_env = self._spawn_env_with_shell(env, shell_exe)
1443
+ if assembled is None:
1444
+ assembled = self._assemble_shell_command(
1445
+ wrapped_command, shell_exe=shell_exe
1446
+ )
1447
+ argv = assembled.spawn_argv(shell_exe, wrapped_command)
1332
1448
  effective_cwd, reset_prefix = _prepare_spawn_cwd(cwd, fallback=fallback_cwd)
1333
1449
  # effective_cwd is None 有两种情况:
1334
1450
  # 1. reset_prefix 为空:用户未指定 cwd,使用当前进程工作目录(有效)
@@ -1348,7 +1464,7 @@ class BashBackend:
1348
1464
  try:
1349
1465
  if _cf:
1350
1466
  proc = subprocess.Popen(
1351
- [shell_exe, "-c", wrapped_command],
1467
+ argv,
1352
1468
  stdin=subprocess.DEVNULL,
1353
1469
  stdout=subprocess.PIPE,
1354
1470
  stderr=subprocess.STDOUT,
@@ -1360,7 +1476,7 @@ class BashBackend:
1360
1476
  )
1361
1477
  else:
1362
1478
  proc = subprocess.Popen(
1363
- [shell_exe, "-c", wrapped_command],
1479
+ argv,
1364
1480
  stdin=subprocess.DEVNULL,
1365
1481
  stdout=subprocess.PIPE,
1366
1482
  stderr=subprocess.STDOUT,
@@ -1553,7 +1669,9 @@ class BashBackend:
1553
1669
  output_file = open(output_path, "wb") # noqa: WPS515
1554
1670
  exit_code_path = os.path.join(task_dir, "exit_code")
1555
1671
 
1556
- command = self._preprocess_shell_command(command)
1672
+ shell_exe = self._posix_shell_executable(env)
1673
+ assembled = self._assemble_shell_command(command, shell_exe=shell_exe)
1674
+ command = assembled.command_string
1557
1675
  quoted_exit_code_path = shlex.quote(exit_code_path)
1558
1676
  wrapped = (
1559
1677
  "{ "
@@ -1563,12 +1681,12 @@ class BashBackend:
1563
1681
  "exit $__AGENTX_EC; "
1564
1682
  "}"
1565
1683
  )
1566
- shell_exe = self._posix_shell_executable(env)
1567
1684
  spawn_env = self._spawn_env_with_shell(env, shell_exe)
1685
+ argv = assembled.spawn_argv(shell_exe, wrapped)
1568
1686
  _cf = self._win32_creationflags()
1569
1687
  if _cf:
1570
1688
  proc = subprocess.Popen(
1571
- [shell_exe, "-c", wrapped],
1689
+ argv,
1572
1690
  stdin=subprocess.DEVNULL,
1573
1691
  stdout=output_file,
1574
1692
  stderr=subprocess.STDOUT,
@@ -1579,7 +1697,7 @@ class BashBackend:
1579
1697
  )
1580
1698
  else:
1581
1699
  proc = subprocess.Popen(
1582
- [shell_exe, "-c", wrapped],
1700
+ argv,
1583
1701
  stdin=subprocess.DEVNULL,
1584
1702
  stdout=output_file,
1585
1703
  stderr=subprocess.STDOUT,
@@ -0,0 +1,146 @@
1
+ """
2
+ bash_exec_assembly.py — Bash 命令装配与 spawn argv(对齐 CC bashProvider)
3
+
4
+ 职责:
5
+ 1) preprocess(nul rewrite + stdin/管道)并记录 before/after(D4)
6
+ 2) 有快照时 ``source snap || true`` + disable extglob + 用户命令(D2 L1/L4)
7
+ 3) ``get_spawn_args``:有可用快照 → ``[-c, cmd]``;否则 ``[-c, -l, cmd]``(对齐 CC)
8
+
9
+ 链路位置:
10
+ ``BashBackend`` 各 Popen / asyncio 路径在 cwd wrap 之前调用。
11
+
12
+ CC 对照:
13
+ ``utils/shell/bashProvider.ts::buildExecCommand`` / ``getSpawnArgs``
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass
20
+
21
+ from .cwd_reporter import bash_single_quoted
22
+ from .shell_quoting import append_stdin_redirect
23
+ from .shell_snapshot import ShellSnapshotService
24
+ from .windows_shell_quoting import rewrite_windows_null_redirect
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PreprocessObservation:
29
+ """D4:preprocess 前后快照。"""
30
+
31
+ before: str
32
+ after: str
33
+
34
+ @property
35
+ def changed(self) -> bool:
36
+ return self.before != self.after
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AssembledShellCommand:
41
+ """装配结果:进入 cwd wrap / Popen 前的命令与 spawn 策略。"""
42
+
43
+ original_command: str
44
+ preprocessed_command: str
45
+ command_string: str
46
+ snapshot_path: str | None
47
+ use_login_shell: bool
48
+ preprocess: PreprocessObservation
49
+
50
+ def spawn_argv(self, shell_exe: str, wrapped_command: str) -> list[str]:
51
+ """对齐 CC ``getSpawnArgs``:``['-c', ...]`` 或 ``['-c', '-l', ...]``。"""
52
+ if self.use_login_shell:
53
+ return [shell_exe, "-c", "-l", wrapped_command]
54
+ return [shell_exe, "-c", wrapped_command]
55
+
56
+ def observability_dict(self) -> dict[str, object]:
57
+ return {
58
+ "command_before": self.preprocess.before,
59
+ "command_after": self.preprocess.after,
60
+ "command_rewritten": self.preprocess.changed,
61
+ "assembled_command": self.command_string,
62
+ "shell_snapshot_path": self.snapshot_path,
63
+ "shell_login_flag": self.use_login_shell,
64
+ }
65
+
66
+
67
+ def disable_extglob_command(shell_path: str) -> str | None:
68
+ """对齐 CC ``getDisableExtglobCommand``(无 SHELL_PREFIX 双写分支)。"""
69
+ base = os.path.basename(shell_path).lower()
70
+ if "bash" in base:
71
+ return "shopt -u extglob 2>/dev/null || true"
72
+ if "zsh" in base:
73
+ return "setopt NO_EXTENDED_GLOB 2>/dev/null || true"
74
+ return None
75
+
76
+
77
+ def preprocess_shell_command(command: str) -> PreprocessObservation:
78
+ after = append_stdin_redirect(rewrite_windows_null_redirect(command))
79
+ return PreprocessObservation(before=command, after=after)
80
+
81
+
82
+ class BashExecAssembler:
83
+ """将用户命令装配为 CC 风格 source/extglob 链,并决定是否 ``-l``。"""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ snapshot_service: ShellSnapshotService | None = None,
89
+ enable_snapshot: bool = True,
90
+ injected_snapshot_path: str | None = None,
91
+ ) -> None:
92
+ self._snapshot_service = snapshot_service or ShellSnapshotService()
93
+ self._enable_snapshot = enable_snapshot
94
+ self._injected_snapshot_path = injected_snapshot_path
95
+ self._cached_snapshot: str | None | object = _UNSET
96
+
97
+ def resolve_snapshot_path(self, shell_exe: str) -> str | None:
98
+ if self._injected_snapshot_path is not None:
99
+ path = self._injected_snapshot_path
100
+ return path if ShellSnapshotService.is_usable(path) else None
101
+ if not self._enable_snapshot:
102
+ return None
103
+ if self._cached_snapshot is _UNSET:
104
+ self._cached_snapshot = self._snapshot_service.create_and_save(shell_exe)
105
+ path = self._cached_snapshot if isinstance(self._cached_snapshot, str) else None
106
+ if not ShellSnapshotService.is_usable(path):
107
+ self._cached_snapshot = None
108
+ return None
109
+ return path
110
+
111
+ def assemble(self, command: str, *, shell_exe: str) -> AssembledShellCommand:
112
+ obs = preprocess_shell_command(command)
113
+ snapshot_path = self.resolve_snapshot_path(shell_exe)
114
+ use_login = snapshot_path is None
115
+ parts: list[str] = []
116
+ if snapshot_path is not None:
117
+ quoted = bash_single_quoted(snapshot_path)
118
+ parts.append(f"source {quoted} 2>/dev/null || true")
119
+ extglob = disable_extglob_command(shell_exe)
120
+ if extglob:
121
+ parts.append(extglob)
122
+ parts.append(obs.after)
123
+ command_string = " && ".join(parts)
124
+ return AssembledShellCommand(
125
+ original_command=command,
126
+ preprocessed_command=obs.after,
127
+ command_string=command_string,
128
+ snapshot_path=snapshot_path,
129
+ use_login_shell=use_login,
130
+ preprocess=obs,
131
+ )
132
+
133
+
134
+ class _UnsetType:
135
+ __slots__ = ()
136
+
137
+
138
+ _UNSET = _UnsetType()
139
+
140
+ __all__ = [
141
+ "AssembledShellCommand",
142
+ "BashExecAssembler",
143
+ "PreprocessObservation",
144
+ "disable_extglob_command",
145
+ "preprocess_shell_command",
146
+ ]
@@ -68,35 +68,51 @@ def _should_use_pipe_eval_fallback(command: str) -> bool:
68
68
 
69
69
 
70
70
  def _split_top_level_pipes(command: str) -> list[str] | None:
71
- """按顶层 ``|`` 分段(不在引号内)。"""
71
+ """按顶层单 ``|`` 分段(不在引号内);**不**拆 ``||`` / ``|&``(BUG-20261101-20)。"""
72
72
  parts: list[str] = []
73
73
  buf: list[str] = []
74
74
  in_single = False
75
75
  in_double = False
76
76
  escape = False
77
+ i = 0
78
+ n = len(command)
77
79
 
78
- for ch in command:
80
+ while i < n:
81
+ ch = command[i]
79
82
  if escape:
80
83
  buf.append(ch)
81
84
  escape = False
85
+ i += 1
82
86
  continue
83
87
  if ch == "\\" and in_double:
84
88
  escape = True
85
89
  buf.append(ch)
90
+ i += 1
86
91
  continue
87
92
  if ch == "'" and not in_double:
88
93
  in_single = not in_single
89
94
  buf.append(ch)
95
+ i += 1
90
96
  continue
91
97
  if ch == '"' and not in_single:
92
98
  in_double = not in_double
93
99
  buf.append(ch)
100
+ i += 1
94
101
  continue
95
102
  if ch == "|" and not in_single and not in_double:
103
+ nxt = command[i + 1] if i + 1 < n else ""
104
+ # ``||`` 逻辑或、``|&`` pipe+stderr:保留为普通字符,不做管道重排分段
105
+ if nxt in ("|", "&"):
106
+ buf.append(ch)
107
+ buf.append(nxt)
108
+ i += 2
109
+ continue
96
110
  parts.append("".join(buf))
97
111
  buf = []
112
+ i += 1
98
113
  continue
99
114
  buf.append(ch)
115
+ i += 1
100
116
 
101
117
  if in_single or in_double:
102
118
  return None
@@ -107,6 +123,11 @@ def _split_top_level_pipes(command: str) -> list[str] | None:
107
123
  return parts
108
124
 
109
125
 
126
+ def _has_rearrangeable_pipe(command: str) -> bool:
127
+ """是否存在可重排的顶层管道(排除仅含 ``||`` 的命令)。"""
128
+ return _split_top_level_pipes(command) is not None
129
+
130
+
110
131
  def _rearrange_pipe_command(command: str) -> str:
111
132
  """
112
133
  管道场景:``eval 'cmd1 < /dev/null | cmd2'``(redirect 在头段,不在 eval 尾)。
@@ -140,6 +161,6 @@ def append_stdin_redirect(command: str) -> str:
140
161
  or has_process_substitution(stripped)
141
162
  ):
142
163
  return command
143
- if "|" in stripped:
164
+ if _has_rearrangeable_pipe(stripped):
144
165
  return _rearrange_pipe_command(stripped)
145
166
  return _eval_with_stdin_redirect(stripped)
@@ -0,0 +1,205 @@
1
+ """
2
+ shell_snapshot.py — Bash/Zsh 环境快照(对齐 CC ShellSnapshot.ts)
3
+
4
+ 职责:
5
+ 会话级导出用户 shell 配置中的 alias / shopt / PATH,写入可 source 的快照文件;
6
+ 供后续每条 Bash 命令 ``source snapshot || true``,避免反复 ``-l`` 的 login 漂移。
7
+
8
+ 链路位置:
9
+ ``BashExecAssembler`` / ``BashBackend`` 在 spawn 前解析快照路径;
10
+ 创建失败时返回 None → spawn 带 ``-l``(对齐 CC getSpawnArgs 回退)。
11
+
12
+ CC 对照:
13
+ ``utils/bash/ShellSnapshot.ts::createAndSaveSnapshot``
14
+ ``utils/shell/bashProvider.ts``(有快照跳过 ``-l``)
15
+
16
+ 当前裁剪范围:
17
+ 不移植 CC 嵌入式 rg/find/grep 影子函数;不读 sessionEnv hooks;
18
+ 快照目录默认 ``~/.langchain_agentx/shell-snapshots``(可用环境变量覆盖)。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import subprocess
26
+ import time
27
+ import uuid
28
+ from pathlib import Path
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ _SNAPSHOT_TIMEOUT_SEC = 10.0
33
+ _DEFAULT_REL = Path(".langchain_agentx") / "shell-snapshots"
34
+
35
+
36
+ def default_snapshots_dir() -> Path:
37
+ override = os.environ.get("LANGCHAIN_AGENTX_SHELL_SNAPSHOT_DIR", "").strip()
38
+ if override:
39
+ return Path(os.path.expanduser(override))
40
+ return Path.home() / _DEFAULT_REL
41
+
42
+
43
+ def resolve_user_config_file(shell_path: str) -> Path:
44
+ home = Path.home()
45
+ lower = os.path.basename(shell_path).lower()
46
+ if "zsh" in lower:
47
+ return home / ".zshrc"
48
+ if "bash" in lower:
49
+ return home / ".bashrc"
50
+ return home / ".profile"
51
+
52
+
53
+ class ShellSnapshotService:
54
+ """创建并校验 shell 快照文件(进程内可复用同一路径)。"""
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ snapshots_dir: Path | None = None,
60
+ timeout_sec: float = _SNAPSHOT_TIMEOUT_SEC,
61
+ ) -> None:
62
+ self._snapshots_dir = snapshots_dir or default_snapshots_dir()
63
+ self._timeout_sec = timeout_sec
64
+
65
+ def create_and_save(self, shell_path: str) -> str | None:
66
+ """对齐 CC ``createAndSaveSnapshot``;失败返回 None。"""
67
+ if os.environ.get("LANGCHAIN_AGENTX_BASH_SKIP_SNAPSHOT", "").strip() == "1":
68
+ return None
69
+ shell_path = os.path.normpath(os.path.realpath(os.path.expanduser(shell_path)))
70
+ if not shell_path or not os.path.isfile(shell_path):
71
+ return None
72
+
73
+ try:
74
+ self._snapshots_dir.mkdir(parents=True, exist_ok=True)
75
+ except OSError as exc:
76
+ _logger.debug("shell snapshot dir create failed: %s", exc)
77
+ return None
78
+
79
+ shell_type = (
80
+ "zsh"
81
+ if "zsh" in os.path.basename(shell_path).lower()
82
+ else "bash"
83
+ if "bash" in os.path.basename(shell_path).lower()
84
+ else "sh"
85
+ )
86
+ stamp = int(time.time() * 1000)
87
+ random_id = uuid.uuid4().hex[:6]
88
+ snapshot_path = self._snapshots_dir / f"snapshot-{shell_type}-{stamp}-{random_id}.sh"
89
+ config_file = resolve_user_config_file(shell_path)
90
+ config_exists = config_file.is_file()
91
+ script = self._build_snapshot_script(
92
+ snapshot_path=snapshot_path,
93
+ config_file=config_file if config_exists else None,
94
+ is_zsh=shell_type == "zsh",
95
+ )
96
+ env = os.environ.copy()
97
+ env["SHELL"] = shell_path
98
+ env.setdefault("GIT_EDITOR", "true")
99
+ try:
100
+ completed = subprocess.run(
101
+ [shell_path, "-c", "-l", script],
102
+ env=env,
103
+ timeout=self._timeout_sec,
104
+ capture_output=True,
105
+ text=True,
106
+ check=False,
107
+ )
108
+ except (OSError, subprocess.TimeoutExpired) as exc:
109
+ _logger.debug("shell snapshot spawn failed: %s", exc)
110
+ self._unlink_quiet(snapshot_path)
111
+ return None
112
+
113
+ if completed.returncode != 0 or not snapshot_path.is_file():
114
+ _logger.debug(
115
+ "shell snapshot creation failed rc=%s stderr=%s",
116
+ completed.returncode,
117
+ (completed.stderr or "")[:300],
118
+ )
119
+ self._unlink_quiet(snapshot_path)
120
+ return None
121
+ return str(snapshot_path)
122
+
123
+ @staticmethod
124
+ def is_usable(path: str | None) -> bool:
125
+ return bool(path) and os.path.isfile(path)
126
+
127
+ @staticmethod
128
+ def _unlink_quiet(path: Path) -> None:
129
+ try:
130
+ path.unlink(missing_ok=True)
131
+ except OSError:
132
+ pass
133
+
134
+ @staticmethod
135
+ def _build_snapshot_script(
136
+ *,
137
+ snapshot_path: Path,
138
+ config_file: Path | None,
139
+ is_zsh: bool,
140
+ ) -> str:
141
+ snap = str(snapshot_path).replace("'", "'\"'\"'")
142
+ lines = [
143
+ f"SNAPSHOT_FILE='{snap}'",
144
+ ]
145
+ if config_file is not None:
146
+ cfg = str(config_file).replace("'", "'\"'\"'")
147
+ lines.append(f"source '{cfg}' < /dev/null")
148
+ else:
149
+ lines.append("# No user config file to source")
150
+
151
+ lines.extend(
152
+ [
153
+ 'echo "# Snapshot file" >| "$SNAPSHOT_FILE"',
154
+ 'echo "# Unset all aliases to avoid conflicts with functions" >> "$SNAPSHOT_FILE"',
155
+ 'echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"',
156
+ ]
157
+ )
158
+
159
+ if is_zsh:
160
+ lines.extend(
161
+ [
162
+ 'echo "# Shell Options" >> "$SNAPSHOT_FILE"',
163
+ 'setopt 2>/dev/null | sed "s/^/setopt /" | head -n 1000 >> "$SNAPSHOT_FILE" || true',
164
+ 'echo "# Aliases" >> "$SNAPSHOT_FILE"',
165
+ "alias 2>/dev/null | sed 's/^alias //g' | sed 's/^/alias -- /' "
166
+ '| head -n 1000 >> "$SNAPSHOT_FILE" || true',
167
+ ]
168
+ )
169
+ else:
170
+ if config_file is None:
171
+ lines.append(
172
+ 'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"'
173
+ )
174
+ lines.extend(
175
+ [
176
+ 'echo "# Shell Options" >> "$SNAPSHOT_FILE"',
177
+ 'shopt -p 2>/dev/null | head -n 1000 >> "$SNAPSHOT_FILE" || true',
178
+ 'set -o 2>/dev/null | grep "on" | awk \'{print "set -o " $1}\' '
179
+ '| head -n 1000 >> "$SNAPSHOT_FILE" || true',
180
+ 'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"',
181
+ 'echo "# Aliases" >> "$SNAPSHOT_FILE"',
182
+ "alias 2>/dev/null | sed 's/^alias //g' | sed 's/^/alias -- /' "
183
+ '| head -n 1000 >> "$SNAPSHOT_FILE" || true',
184
+ ]
185
+ )
186
+
187
+ path_value = os.environ.get("PATH", "")
188
+ path_q = path_value.replace("'", "'\"'\"'")
189
+ lines.append(f'echo "export PATH=\'{path_q}\'" >> "$SNAPSHOT_FILE"')
190
+ lines.extend(
191
+ [
192
+ 'if [ ! -f "$SNAPSHOT_FILE" ]; then',
193
+ ' echo "Error: Snapshot file was not created" >&2',
194
+ " exit 1",
195
+ "fi",
196
+ ]
197
+ )
198
+ return "\n".join(lines)
199
+
200
+
201
+ __all__ = [
202
+ "ShellSnapshotService",
203
+ "default_snapshots_dir",
204
+ "resolve_user_config_file",
205
+ ]
@@ -818,6 +818,26 @@ class BashRuntimeTool(RuntimeTool):
818
818
  auto_backgrounded=False,
819
819
  )
820
820
  ev.add("task_state_changed", from_state="running", to_state=snapshot.task_status)
821
+ obs_payload = finalize_observability_payload(ev.to_observability())
822
+ shell_obs = {
823
+ k: v
824
+ for k, v in {
825
+ "command_before": result.command_before,
826
+ "command_after": result.command_after,
827
+ "shell_snapshot_path": result.shell_snapshot_path,
828
+ "shell_login_flag": result.shell_login_flag,
829
+ "command_rewritten": (
830
+ result.command_before is not None
831
+ and result.command_after is not None
832
+ and result.command_before != result.command_after
833
+ ),
834
+ }.items()
835
+ if v is not None
836
+ }
837
+ if shell_obs:
838
+ merged_obs = dict(obs_payload or {})
839
+ merged_obs["shell_assembly"] = shell_obs
840
+ obs_payload = merged_obs
821
841
  return BashToolOutput(
822
842
  stdout=stdout_text,
823
843
  stderr=result.stderr,
@@ -838,7 +858,7 @@ class BashRuntimeTool(RuntimeTool):
838
858
  cwd_commit_allowed=cwd_commit_allowed,
839
859
  overflow_file=overflow_file,
840
860
  output_truncated=output_truncated,
841
- observability=finalize_observability_payload(ev.to_observability()),
861
+ observability=obs_payload,
842
862
  )
843
863
 
844
864
  def collect_side_effects(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langchain-agentx-python
3
- Version: 2.4.5
3
+ Version: 2.4.7
4
4
  Summary: LangChain/LangGraph-based agent utilities for CodeBaseX.
5
5
  Author-email: GoodMood2008 <GoodMood2008@users.noreply.github.com>
6
6
  License: Apache License
@@ -1,4 +1,4 @@
1
- langchain_agentx/__init__.py,sha256=pZsAuwiCt4PE3SIZy-8M2i69KqjY9ZHafj9Y8CUjpmA,2014
1
+ langchain_agentx/__init__.py,sha256=N_Fs7Pkm2H6v6bByXzym_2aQuA4Ef7x0K2Sd2IN-5Ho,2014
2
2
  langchain_agentx/command/__init__.py,sha256=Ej260S5uFQcmEtGZQG_NH7BYjHoStrpBLtGUsBTxyZk,631
3
3
  langchain_agentx/command/allowed_tools.py,sha256=TVA0VM8rm98H-QbLK_GRiX5RhEAZFxKawliMi4kk96A,3359
4
4
  langchain_agentx/command/context.py,sha256=DIGOGPBw5UGDBm0WNNJlKx1h_9rCRmcdROv4DT61Qug,731
@@ -146,7 +146,7 @@ langchain_agentx/loop/subagent/active_run_registry.py,sha256=KurhAMDTBnRkdxNidaM
146
146
  langchain_agentx/loop/subagent/async_runner.py,sha256=ZJu6DztRmg0SNEapEuPyQbsk8SprXiMKWDWDHb9L7yQ,14124
147
147
  langchain_agentx/loop/subagent/boundary_snapshot.py,sha256=9cQuqN29nJDRnHnx9c8b09KRmODE2zv15Z06QyjVJps,10008
148
148
  langchain_agentx/loop/subagent/cache_safe_params.py,sha256=-qcgdazEYRy6s4ILQuttpxraE3gsxq17Ro7LYBnuv3E,4460
149
- langchain_agentx/loop/subagent/context.py,sha256=cSyXYu9l5MnHQMEJkCQFTZKhn3pnIR6nGgGKzd_vhr8,25099
149
+ langchain_agentx/loop/subagent/context.py,sha256=YAAR_g7BA_PXL5LGxxO1xWMn5pvVU3VOYSmxdxNPbbg,25243
150
150
  langchain_agentx/loop/subagent/definition_permission.py,sha256=_Y30FA0EDTgqpqjw5luIiwbpABLo17myC3aY4NWbGIw,2188
151
151
  langchain_agentx/loop/subagent/display_projector.py,sha256=AKsGWS1c9reH0kuD5u4oQ24KsBgyt7X10iZM3ere6Fs,3504
152
152
  langchain_agentx/loop/subagent/fork_boilerplate.py,sha256=2Uh3cwegL7V-DvtLIxjX7ik5uW5mXfY2NBzP-biREso,5005
@@ -489,7 +489,7 @@ langchain_agentx/tool_runtime/read_ignore_patterns.py,sha256=l4kNulVDaOdQxcs4FeC
489
489
  langchain_agentx/tool_runtime/read_permission.py,sha256=rwH7NEtL57aUKXVjSf6hMOOvEWOayNFyvI_SNoNCHPA,9284
490
490
  langchain_agentx/tool_runtime/registry.py,sha256=cvdMRz5cyAqLjM0MWWp6wu1DgqtZeMoiR2vgyAVHlb8,3358
491
491
  langchain_agentx/tool_runtime/session_hints.py,sha256=TyoNdSw5GsE5AvYDPHJKsrto9YXZtbAdihI8YKHdRxA,3004
492
- langchain_agentx/tool_runtime/session_store.py,sha256=KEapYa7scu-KBl5R1WwlOOU9J3lynYWgzggtnkiIsRE,17235
492
+ langchain_agentx/tool_runtime/session_store.py,sha256=47asao4mHf75BijGg2OrJhexCUk2k8w3GpMUGYirNxM,17625
493
493
  langchain_agentx/tool_runtime/shell_error.py,sha256=gi2i0sc4qHcUiLD5B2yk-wIvyVaOpLFKyf4ZnnKmAuE,2198
494
494
  langchain_agentx/tool_runtime/smoke_test_runtime.py,sha256=vOdoYdFwsWjq8k7rHD7hkgtgM5noGr9a6Wn1hHZ7354,11846
495
495
  langchain_agentx/tool_runtime/state_bridge.py,sha256=8F7jG8Uk4EVJaAB8rwKpNmbWrNZtIHYJO5RFBiwSRhs,6011
@@ -588,7 +588,8 @@ langchain_agentx/tools/ask_user_question/validators.py,sha256=Uc88cBzqUp0PNG_Jf2
588
588
  langchain_agentx/tools/bash/__init__.py,sha256=molPHFDylE2wWG_aQx7fb0ciAx3wWpdXzIixTZV5ne4,214
589
589
  langchain_agentx/tools/bash/ast_security.py,sha256=vMP1lAi2bHrsHojun64eu1-Ym8hGW00dSVe3PexbVxc,22999
590
590
  langchain_agentx/tools/bash/auto_mode_adapter.py,sha256=7-yb_kGmpytBYkD2Hn5YWPIceo_YN4PatEKj8O7HKsM,4243
591
- langchain_agentx/tools/bash/backend.py,sha256=E0DMBAENZLUSLvuEh6RubNJ6iCVnx1d2VGsQzVuLA5k,65361
591
+ langchain_agentx/tools/bash/backend.py,sha256=aqLNdaBkD66c7jMPgguBLr9JYkCb5fsxsOBLGFbdjlk,70285
592
+ langchain_agentx/tools/bash/bash_exec_assembly.py,sha256=Rb1spjxVZ_-H4D5zOdTiPY8d7cwTRYAs2nTi-zW8zuA,5023
592
593
  langchain_agentx/tools/bash/bash_hardening.py,sha256=KEYKRC7kS_to4mzXuChvCmOIIb4dSUkOb9K1MBDtWDc,29296
593
594
  langchain_agentx/tools/bash/bash_registry_content.py,sha256=YHE3PwuLBeFrEux4xNaDZjJpYtUSMFPLYr6A7g-fFxk,3569
594
595
  langchain_agentx/tools/bash/bash_runtime_contract.py,sha256=s8vrvMl7405KLGw2hPFbzlzWohm5JX3c9cImMBgqh8I,1511
@@ -615,10 +616,11 @@ langchain_agentx/tools/bash/sed_validation.py,sha256=HA-zWe8Wo4LbgC9x8ZMQLzkd73i
615
616
  langchain_agentx/tools/bash/semantic_matrix.py,sha256=stvsudu8mGjS1JJCawMAUKabt-7co90Q8qZgi063QSw,3285
616
617
  langchain_agentx/tools/bash/semantics.py,sha256=pkf4URTulAOVL7m04Rq9JJF7z0J2mTEsGWrjyoY9r6o,5769
617
618
  langchain_agentx/tools/bash/shell_locator.py,sha256=AUnCZS8gmZurCs8rL4y0MMOS9UZimfg5rQ5At38mZKU,6296
618
- langchain_agentx/tools/bash/shell_quoting.py,sha256=K4JLVGwU8VpdQzfQB1o3xsq4HoCKHew1pry8hHChcoA,4498
619
+ langchain_agentx/tools/bash/shell_quoting.py,sha256=qh5hl9rgjdhOijkM_MHdodqkj5VuCy68DzIA8zS2Qxw,5223
619
620
  langchain_agentx/tools/bash/shell_rule_matching.py,sha256=uYEKg2C5M1r0eh3L4b5NCML1Gj2UBdIXeL_u2A6_RyM,3981
621
+ langchain_agentx/tools/bash/shell_snapshot.py,sha256=Fxj8natFh0vuOapl3Gy3XIL2UdDKK-8G3CCvn0RKGFg,7181
620
622
  langchain_agentx/tools/bash/task_runtime.py,sha256=hikVC9YugtvpLf4nDaPlX_GFem1YQvDRwN0ShRflUYU,3568
621
- langchain_agentx/tools/bash/tool.py,sha256=O8wDLMsPM9ryx_BfKo_SaGtlyRb7UyUxDAmOEgUgIek,46699
623
+ langchain_agentx/tools/bash/tool.py,sha256=XNxdyG0ayxrIdvUZdV_dv46UC_48uOnlB1QN2-wdb4w,47499
622
624
  langchain_agentx/tools/bash/windows_shell_quoting.py,sha256=0RzLnTKyFrJ536QzqPZvSyUcQWN794XRlAq2RdHeKHo,1615
623
625
  langchain_agentx/tools/edit/__init__.py,sha256=PiiOHUODUzTRQxxPdE7ZMTjd3vfdzm9LEcn1lpVtg8o,203
624
626
  langchain_agentx/tools/edit/backend.py,sha256=JrikYafZDGMQ2JjXRxbnv1VSHVGRUdLmZN__nnqkM9o,3159
@@ -839,8 +841,8 @@ langchain_agentx/workspace/root_grant_manager.py,sha256=W3gy8HTevbERbXqIgRcjzOXO
839
841
  langchain_agentx/workspace/tool_boundary.py,sha256=UDwX6swpSLsx9HNkYuuRRiV5o7ZZG_Bru2Yn0c5kNX8,5724
840
842
  langchain_agentx/workspace/validators.py,sha256=tQt-6TOcL8Fw7Ig5ebA9S7vGWh1rby920eFW6x8Tk9E,1439
841
843
  langchain_agentx/workspace/view.py,sha256=PGasqTaqhlD03SXXazHuw4RHfV681AIdlsYqL70pEjc,4774
842
- langchain_agentx_python-2.4.5.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
843
- langchain_agentx_python-2.4.5.dist-info/METADATA,sha256=aK5l6-y3n1a8k25zAxJ8vlWQYMaMDiCH8ReZj9Faka8,23971
844
- langchain_agentx_python-2.4.5.dist-info/WHEEL,sha256=51RkbunBAw4BWsgaQWTpPhg4Diwp3c9P5iaLk67Hdtg,92
845
- langchain_agentx_python-2.4.5.dist-info/top_level.txt,sha256=Ge284pniNt8xea0OLk2o9o32GqVpDhOYk20fwE-0xxA,17
846
- langchain_agentx_python-2.4.5.dist-info/RECORD,,
844
+ langchain_agentx_python-2.4.7.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
845
+ langchain_agentx_python-2.4.7.dist-info/METADATA,sha256=rvN_Qn80V-J-HbrRDQBQHw3GdjhEvDO6szODBFZXHhY,23971
846
+ langchain_agentx_python-2.4.7.dist-info/WHEEL,sha256=51RkbunBAw4BWsgaQWTpPhg4Diwp3c9P5iaLk67Hdtg,92
847
+ langchain_agentx_python-2.4.7.dist-info/top_level.txt,sha256=Ge284pniNt8xea0OLk2o9o32GqVpDhOYk20fwE-0xxA,17
848
+ langchain_agentx_python-2.4.7.dist-info/RECORD,,