langchain-agentx-python 0.1__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.
Files changed (354) hide show
  1. langchain_agentx/__init__.py +46 -0
  2. langchain_agentx/command/__init__.py +28 -0
  3. langchain_agentx/command/builtin/__init__.py +25 -0
  4. langchain_agentx/command/builtin/clear.py +33 -0
  5. langchain_agentx/command/builtin/compact.py +33 -0
  6. langchain_agentx/command/builtin/memory.py +37 -0
  7. langchain_agentx/command/builtin/reload_plugins.py +42 -0
  8. langchain_agentx/command/context.py +30 -0
  9. langchain_agentx/command/dispatcher.py +183 -0
  10. langchain_agentx/command/registry.py +110 -0
  11. langchain_agentx/command/result.py +25 -0
  12. langchain_agentx/command/types.py +41 -0
  13. langchain_agentx/config/__init__.py +14 -0
  14. langchain_agentx/loop/__init__.py +47 -0
  15. langchain_agentx/loop/config/__init__.py +20 -0
  16. langchain_agentx/loop/config/agent_config.py +66 -0
  17. langchain_agentx/loop/config/agent_loop_config.py +72 -0
  18. langchain_agentx/loop/config/model_context_resolver.py +105 -0
  19. langchain_agentx/loop/config/runtime_settings.py +50 -0
  20. langchain_agentx/loop/config/token_estimator.py +133 -0
  21. langchain_agentx/loop/context/__init__.py +66 -0
  22. langchain_agentx/loop/context/blocking_guard.py +97 -0
  23. langchain_agentx/loop/context/compaction_service.py +60 -0
  24. langchain_agentx/loop/context/message_utils.py +56 -0
  25. langchain_agentx/loop/context/pipeline.py +127 -0
  26. langchain_agentx/loop/context/settings.py +103 -0
  27. langchain_agentx/loop/context/stages/__init__.py +29 -0
  28. langchain_agentx/loop/context/stages/autocompact.py +140 -0
  29. langchain_agentx/loop/context/stages/base.py +32 -0
  30. langchain_agentx/loop/context/stages/collapse.py +76 -0
  31. langchain_agentx/loop/context/stages/microcompact.py +76 -0
  32. langchain_agentx/loop/context/stages/noop.py +33 -0
  33. langchain_agentx/loop/context/stages/snip.py +71 -0
  34. langchain_agentx/loop/context/stages/tool_result_budget.py +69 -0
  35. langchain_agentx/loop/context/types.py +79 -0
  36. langchain_agentx/loop/exit/__init__.py +1 -0
  37. langchain_agentx/loop/exit/exit_logic.py +320 -0
  38. langchain_agentx/loop/exit/reason_codes.py +39 -0
  39. langchain_agentx/loop/graph/__init__.py +5 -0
  40. langchain_agentx/loop/graph/builtin_loop_control.py +197 -0
  41. langchain_agentx/loop/graph/factory.py +1409 -0
  42. langchain_agentx/loop/graph/graph_edges.py +820 -0
  43. langchain_agentx/loop/hook/__init__.py +48 -0
  44. langchain_agentx/loop/hook/async_hook_runner.py +62 -0
  45. langchain_agentx/loop/hook/config.py +280 -0
  46. langchain_agentx/loop/hook/engine.py +321 -0
  47. langchain_agentx/loop/hook/executors/__init__.py +9 -0
  48. langchain_agentx/loop/hook/executors/agent.py +107 -0
  49. langchain_agentx/loop/hook/executors/command.py +230 -0
  50. langchain_agentx/loop/hook/executors/http.py +114 -0
  51. langchain_agentx/loop/hook/executors/prompt.py +92 -0
  52. langchain_agentx/loop/hook/graph_wiring.py +134 -0
  53. langchain_agentx/loop/hook/registry.py +262 -0
  54. langchain_agentx/loop/hook/trust.py +43 -0
  55. langchain_agentx/loop/hook/types.py +110 -0
  56. langchain_agentx/loop/injection/__init__.py +13 -0
  57. langchain_agentx/loop/injection/dedup.py +74 -0
  58. langchain_agentx/loop/loop_abort.py +36 -0
  59. langchain_agentx/loop/model/__init__.py +1 -0
  60. langchain_agentx/loop/model/model_node.py +648 -0
  61. langchain_agentx/loop/model/model_nodes.py +661 -0
  62. langchain_agentx/loop/model/orphan_tool_results.py +38 -0
  63. langchain_agentx/loop/model/retrier.py +307 -0
  64. langchain_agentx/loop/model/retry_bridge.py +58 -0
  65. langchain_agentx/loop/model/retry_events.py +35 -0
  66. langchain_agentx/loop/model/retry_policy.py +56 -0
  67. langchain_agentx/loop/model/schema_and_format.py +153 -0
  68. langchain_agentx/loop/model/tool_and_model_binding.py +227 -0
  69. langchain_agentx/loop/model/tool_call_degradation_corrector.py +443 -0
  70. langchain_agentx/loop/model/tool_transcript_guard.py +225 -0
  71. langchain_agentx/loop/prompt/__init__.py +95 -0
  72. langchain_agentx/loop/prompt/builder.py +61 -0
  73. langchain_agentx/loop/prompt/builtin.py +218 -0
  74. langchain_agentx/loop/prompt/compact.py +408 -0
  75. langchain_agentx/loop/prompt/sections.py +120 -0
  76. langchain_agentx/loop/runtime/__init__.py +19 -0
  77. langchain_agentx/loop/runtime/context.py +34 -0
  78. langchain_agentx/loop/runtime/context_factory.py +107 -0
  79. langchain_agentx/loop/runtime/subagent_execution_paths.py +68 -0
  80. langchain_agentx/loop/subagent/__init__.py +53 -0
  81. langchain_agentx/loop/subagent/async_runner.py +215 -0
  82. langchain_agentx/loop/subagent/context.py +209 -0
  83. langchain_agentx/loop/subagent/fork_worktree_notice.py +25 -0
  84. langchain_agentx/loop/subagent/graph.py +72 -0
  85. langchain_agentx/loop/subagent/orchestrator.py +391 -0
  86. langchain_agentx/loop/subagent/progress.py +30 -0
  87. langchain_agentx/loop/subagent/prompt.py +52 -0
  88. langchain_agentx/loop/subagent/runner.py +504 -0
  89. langchain_agentx/loop/subagent/transcript.py +172 -0
  90. langchain_agentx/memory/__init__.py +2 -0
  91. langchain_agentx/memory/instruction/__init__.py +12 -0
  92. langchain_agentx/memory/instruction/loader.py +325 -0
  93. langchain_agentx/memory/instruction/resolver.py +24 -0
  94. langchain_agentx/memory/instruction/runtime.py +83 -0
  95. langchain_agentx/memory/instruction/sections.py +83 -0
  96. langchain_agentx/memory/instruction/types.py +59 -0
  97. langchain_agentx/memory/memdir/__init__.py +77 -0
  98. langchain_agentx/memory/memdir/age.py +36 -0
  99. langchain_agentx/memory/memdir/agent_memory.py +380 -0
  100. langchain_agentx/memory/memdir/extractor.py +309 -0
  101. langchain_agentx/memory/memdir/loader.py +187 -0
  102. langchain_agentx/memory/memdir/paths.py +63 -0
  103. langchain_agentx/memory/memdir/recall.py +45 -0
  104. langchain_agentx/memory/memdir/runtime.py +43 -0
  105. langchain_agentx/memory/memdir/scan.py +135 -0
  106. langchain_agentx/memory/memdir/types.py +104 -0
  107. langchain_agentx/memory/session/__init__.py +76 -0
  108. langchain_agentx/memory/session/compact_bridge.py +208 -0
  109. langchain_agentx/memory/session/prompts.py +172 -0
  110. langchain_agentx/memory/session/session_memory.py +282 -0
  111. langchain_agentx/observability/__init__.py +67 -0
  112. langchain_agentx/observability/evaluation/__init__.py +17 -0
  113. langchain_agentx/observability/evaluation/checkers/__init__.py +18 -0
  114. langchain_agentx/observability/evaluation/checkers/base.py +34 -0
  115. langchain_agentx/observability/evaluation/checkers/compaction.py +38 -0
  116. langchain_agentx/observability/evaluation/checkers/degradation.py +50 -0
  117. langchain_agentx/observability/evaluation/checkers/exit_quality.py +42 -0
  118. langchain_agentx/observability/evaluation/checkers/session_memory.py +45 -0
  119. langchain_agentx/observability/evaluation/checkers/tool_behavior.py +53 -0
  120. langchain_agentx/observability/evaluation/retention_scheduler.py +67 -0
  121. langchain_agentx/observability/evaluation/service.py +102 -0
  122. langchain_agentx/observability/evaluation/state.py +32 -0
  123. langchain_agentx/observability/evaluation/store.py +258 -0
  124. langchain_agentx/observability/events/__init__.py +15 -0
  125. langchain_agentx/observability/events/langchain_agentx_event_adapter.py +832 -0
  126. langchain_agentx/observability/logging/__init__.py +15 -0
  127. langchain_agentx/observability/logging/debug_burst.py +95 -0
  128. langchain_agentx/observability/logging/logging_config.py +178 -0
  129. langchain_agentx/observability/logging/logging_contract.py +65 -0
  130. langchain_agentx/observability/replay/__init__.py +35 -0
  131. langchain_agentx/observability/replay/cli.py +91 -0
  132. langchain_agentx/observability/replay/service.py +83 -0
  133. langchain_agentx/observability/replay/store.py +278 -0
  134. langchain_agentx/observability/replay/ui.py +47 -0
  135. langchain_agentx/observability/trace/__init__.py +25 -0
  136. langchain_agentx/observability/trace/collector.py +560 -0
  137. langchain_agentx/observability/trace/event_emitter.py +183 -0
  138. langchain_agentx/observability/trace/hook_event_emitter.py +49 -0
  139. langchain_agentx/observability/trace/models.py +144 -0
  140. langchain_agentx/observability/trace/sqlite_store.py +873 -0
  141. langchain_agentx/observability/trace/trace_callback.py +295 -0
  142. langchain_agentx/observability/trace/trace_lifecycle_collector.py +114 -0
  143. langchain_agentx/plugin/__init__.py +26 -0
  144. langchain_agentx/plugin/builtin.py +53 -0
  145. langchain_agentx/plugin/config.py +113 -0
  146. langchain_agentx/plugin/loader.py +386 -0
  147. langchain_agentx/plugin/manifest.py +154 -0
  148. langchain_agentx/plugin/registries.py +211 -0
  149. langchain_agentx/plugin/types.py +142 -0
  150. langchain_agentx/provider/__init__.py +27 -0
  151. langchain_agentx/provider/anthropic.py +121 -0
  152. langchain_agentx/provider/compatible_chat_openai.py +86 -0
  153. langchain_agentx/provider/env.py +45 -0
  154. langchain_agentx/provider/model_profile.py +156 -0
  155. langchain_agentx/provider/openai.py +89 -0
  156. langchain_agentx/session/__init__.py +17 -0
  157. langchain_agentx/session/agent_session.py +320 -0
  158. langchain_agentx/session/conversation_factory.py +87 -0
  159. langchain_agentx/session/conversation_recovery.py +156 -0
  160. langchain_agentx/session/conversation_session.py +198 -0
  161. langchain_agentx/session/factory.py +143 -0
  162. langchain_agentx/session/protocol.py +25 -0
  163. langchain_agentx/task_runtime/__init__.py +113 -0
  164. langchain_agentx/task_runtime/core/__init__.py +51 -0
  165. langchain_agentx/task_runtime/core/ids.py +33 -0
  166. langchain_agentx/task_runtime/core/interfaces.py +115 -0
  167. langchain_agentx/task_runtime/core/notification_priority.py +19 -0
  168. langchain_agentx/task_runtime/core/types.py +136 -0
  169. langchain_agentx/task_runtime/integrations/__init__.py +33 -0
  170. langchain_agentx/task_runtime/integrations/loop_adapter.py +91 -0
  171. langchain_agentx/task_runtime/integrations/loop_integration.py +61 -0
  172. langchain_agentx/task_runtime/integrations/prefetch_providers.py +108 -0
  173. langchain_agentx/task_runtime/integrations/provider_factory.py +103 -0
  174. langchain_agentx/task_runtime/integrations/queued_command_provider.py +184 -0
  175. langchain_agentx/task_runtime/integrations/sqlite_queued_command_provider.py +338 -0
  176. langchain_agentx/task_runtime/integrations/tool_use_summary_provider.py +254 -0
  177. langchain_agentx/task_runtime/orchestrator/__init__.py +5 -0
  178. langchain_agentx/task_runtime/orchestrator/runtime.py +386 -0
  179. langchain_agentx/task_runtime/output/__init__.py +5 -0
  180. langchain_agentx/task_runtime/output/sink.py +64 -0
  181. langchain_agentx/task_runtime/policy/__init__.py +11 -0
  182. langchain_agentx/task_runtime/policy/withhold_visibility.py +32 -0
  183. langchain_agentx/task_runtime/queue/__init__.py +5 -0
  184. langchain_agentx/task_runtime/queue/in_memory.py +55 -0
  185. langchain_agentx/task_runtime/skill_prefetch/__init__.py +4 -0
  186. langchain_agentx/task_runtime/skill_prefetch/attachments.py +46 -0
  187. langchain_agentx/task_runtime/skill_prefetch/models.py +37 -0
  188. langchain_agentx/task_runtime/skill_prefetch/provider.py +344 -0
  189. langchain_agentx/task_runtime/store/__init__.py +6 -0
  190. langchain_agentx/task_runtime/store/in_memory.py +81 -0
  191. langchain_agentx/task_runtime/store/sqlite_store.py +281 -0
  192. langchain_agentx/task_runtime/tasks/__init__.py +76 -0
  193. langchain_agentx/task_runtime/tasks/ai_analysis/__init__.py +15 -0
  194. langchain_agentx/task_runtime/tasks/ai_analysis/base.py +41 -0
  195. langchain_agentx/task_runtime/tasks/ai_analysis/evaluation.py +67 -0
  196. langchain_agentx/task_runtime/tasks/ai_analysis/registry.py +36 -0
  197. langchain_agentx/task_runtime/tasks/ai_analysis/scheduler.py +70 -0
  198. langchain_agentx/task_runtime/tasks/base/__init__.py +6 -0
  199. langchain_agentx/task_runtime/tasks/base/contracts.py +24 -0
  200. langchain_agentx/task_runtime/tasks/custom/__init__.py +7 -0
  201. langchain_agentx/task_runtime/tasks/custom/executor.py +60 -0
  202. langchain_agentx/task_runtime/tasks/custom/notification.py +7 -0
  203. langchain_agentx/task_runtime/tasks/custom/semantics.py +13 -0
  204. langchain_agentx/task_runtime/tasks/custom/spec.py +33 -0
  205. langchain_agentx/task_runtime/tasks/dream_task/__init__.py +15 -0
  206. langchain_agentx/task_runtime/tasks/dream_task/executor.py +61 -0
  207. langchain_agentx/task_runtime/tasks/dream_task/notification.py +19 -0
  208. langchain_agentx/task_runtime/tasks/dream_task/semantics.py +13 -0
  209. langchain_agentx/task_runtime/tasks/dream_task/spec.py +35 -0
  210. langchain_agentx/task_runtime/tasks/dream_task/state.py +17 -0
  211. langchain_agentx/task_runtime/tasks/in_process_teammate/__init__.py +12 -0
  212. langchain_agentx/task_runtime/tasks/in_process_teammate/executor.py +36 -0
  213. langchain_agentx/task_runtime/tasks/in_process_teammate/notification.py +25 -0
  214. langchain_agentx/task_runtime/tasks/in_process_teammate/semantics.py +13 -0
  215. langchain_agentx/task_runtime/tasks/in_process_teammate/spec.py +63 -0
  216. langchain_agentx/task_runtime/tasks/local_agent/__init__.py +14 -0
  217. langchain_agentx/task_runtime/tasks/local_agent/executor.py +33 -0
  218. langchain_agentx/task_runtime/tasks/local_agent/notification.py +21 -0
  219. langchain_agentx/task_runtime/tasks/local_agent/runner.py +43 -0
  220. langchain_agentx/task_runtime/tasks/local_agent/semantics.py +13 -0
  221. langchain_agentx/task_runtime/tasks/local_agent/spec.py +31 -0
  222. langchain_agentx/task_runtime/tasks/local_bash/__init__.py +13 -0
  223. langchain_agentx/task_runtime/tasks/local_bash/executor.py +95 -0
  224. langchain_agentx/task_runtime/tasks/local_bash/notification.py +22 -0
  225. langchain_agentx/task_runtime/tasks/local_bash/semantics.py +13 -0
  226. langchain_agentx/task_runtime/tasks/local_bash/spec.py +55 -0
  227. langchain_agentx/task_runtime/tasks/remote_agent/__init__.py +19 -0
  228. langchain_agentx/task_runtime/tasks/remote_agent/backend.py +76 -0
  229. langchain_agentx/task_runtime/tasks/remote_agent/executor.py +37 -0
  230. langchain_agentx/task_runtime/tasks/remote_agent/notification.py +22 -0
  231. langchain_agentx/task_runtime/tasks/remote_agent/semantics.py +13 -0
  232. langchain_agentx/task_runtime/tasks/remote_agent/spec.py +34 -0
  233. langchain_agentx/task_runtime/tasks/trace_cleanup/__init__.py +19 -0
  234. langchain_agentx/task_runtime/tasks/trace_cleanup/bootstrap.py +95 -0
  235. langchain_agentx/task_runtime/tasks/trace_cleanup/executor.py +66 -0
  236. langchain_agentx/task_runtime/tasks/trace_cleanup/scheduler.py +169 -0
  237. langchain_agentx/tool_runtime/__init__.py +90 -0
  238. langchain_agentx/tool_runtime/adapter.py +365 -0
  239. langchain_agentx/tool_runtime/base.py +319 -0
  240. langchain_agentx/tool_runtime/errors.py +190 -0
  241. langchain_agentx/tool_runtime/identical_call_cache.py +110 -0
  242. langchain_agentx/tool_runtime/loader.py +195 -0
  243. langchain_agentx/tool_runtime/models.py +260 -0
  244. langchain_agentx/tool_runtime/permission_context.py +78 -0
  245. langchain_agentx/tool_runtime/pipeline.py +621 -0
  246. langchain_agentx/tool_runtime/policy.py +447 -0
  247. langchain_agentx/tool_runtime/registry.py +81 -0
  248. langchain_agentx/tool_runtime/resolvers/__init__.py +27 -0
  249. langchain_agentx/tool_runtime/resolvers/agent_session.py +125 -0
  250. langchain_agentx/tool_runtime/resolvers/background.py +32 -0
  251. langchain_agentx/tool_runtime/resolvers/base.py +20 -0
  252. langchain_agentx/tool_runtime/resolvers/conversation.py +22 -0
  253. langchain_agentx/tool_runtime/resolvers/workflow.py +73 -0
  254. langchain_agentx/tool_runtime/session_store.py +132 -0
  255. langchain_agentx/tool_runtime/smoke_test_runtime.py +294 -0
  256. langchain_agentx/tool_runtime/state_bridge.py +164 -0
  257. langchain_agentx/tools/__init__.py +26 -0
  258. langchain_agentx/tools/agent/__init__.py +9 -0
  259. langchain_agentx/tools/agent/backend.py +53 -0
  260. langchain_agentx/tools/agent/built_in/__init__.py +19 -0
  261. langchain_agentx/tools/agent/built_in/agentx_guide.py +65 -0
  262. langchain_agentx/tools/agent/built_in/explore.py +80 -0
  263. langchain_agentx/tools/agent/built_in/general.py +57 -0
  264. langchain_agentx/tools/agent/built_in/plan.py +89 -0
  265. langchain_agentx/tools/agent/built_in/statusline_setup.py +64 -0
  266. langchain_agentx/tools/agent/built_in/verification.py +120 -0
  267. langchain_agentx/tools/agent/builtin_subagent_loader.py +89 -0
  268. langchain_agentx/tools/agent/cwd_resolution.py +119 -0
  269. langchain_agentx/tools/agent/limits.py +26 -0
  270. langchain_agentx/tools/agent/loader.py +270 -0
  271. langchain_agentx/tools/agent/models.py +85 -0
  272. langchain_agentx/tools/agent/prompt.py +120 -0
  273. langchain_agentx/tools/agent/registry/__init__.py +18 -0
  274. langchain_agentx/tools/agent/registry/config.py +29 -0
  275. langchain_agentx/tools/agent/registry/registry.py +47 -0
  276. langchain_agentx/tools/agent/scope.py +137 -0
  277. langchain_agentx/tools/agent/tool.py +256 -0
  278. langchain_agentx/tools/bash/__init__.py +9 -0
  279. langchain_agentx/tools/bash/ast_security.py +571 -0
  280. langchain_agentx/tools/bash/backend.py +1447 -0
  281. langchain_agentx/tools/bash/bash_hardening.py +734 -0
  282. langchain_agentx/tools/bash/bash_runtime_contract.py +41 -0
  283. langchain_agentx/tools/bash/cwd_reporter.py +95 -0
  284. langchain_agentx/tools/bash/limits.py +71 -0
  285. langchain_agentx/tools/bash/mode_validation.py +282 -0
  286. langchain_agentx/tools/bash/models.py +131 -0
  287. langchain_agentx/tools/bash/observability.py +148 -0
  288. langchain_agentx/tools/bash/output_utils.py +200 -0
  289. langchain_agentx/tools/bash/path_security.py +2429 -0
  290. langchain_agentx/tools/bash/prompt.py +68 -0
  291. langchain_agentx/tools/bash/read_only_validation.py +589 -0
  292. langchain_agentx/tools/bash/result_presenter.py +324 -0
  293. langchain_agentx/tools/bash/sandbox_decision.py +133 -0
  294. langchain_agentx/tools/bash/security.py +311 -0
  295. langchain_agentx/tools/bash/sed_edit_parser.py +243 -0
  296. langchain_agentx/tools/bash/sed_validation.py +163 -0
  297. langchain_agentx/tools/bash/semantics.py +111 -0
  298. langchain_agentx/tools/bash/session_manager.py +205 -0
  299. langchain_agentx/tools/bash/session_runtime.py +290 -0
  300. langchain_agentx/tools/bash/shell_locator.py +191 -0
  301. langchain_agentx/tools/bash/task_runtime.py +91 -0
  302. langchain_agentx/tools/bash/tool.py +939 -0
  303. langchain_agentx/tools/bash/windows_shell_quoting.py +45 -0
  304. langchain_agentx/tools/glob/__init__.py +9 -0
  305. langchain_agentx/tools/glob/models.py +57 -0
  306. langchain_agentx/tools/glob/pagination.py +30 -0
  307. langchain_agentx/tools/glob/prompt.py +24 -0
  308. langchain_agentx/tools/glob/rg_list_backend.py +139 -0
  309. langchain_agentx/tools/glob/rg_pattern.py +44 -0
  310. langchain_agentx/tools/glob/tool.py +327 -0
  311. langchain_agentx/tools/grep/__init__.py +7 -0
  312. langchain_agentx/tools/grep/backend.py +375 -0
  313. langchain_agentx/tools/grep/models.py +127 -0
  314. langchain_agentx/tools/grep/prompt.py +30 -0
  315. langchain_agentx/tools/grep/rg_subprocess_controller.py +114 -0
  316. langchain_agentx/tools/grep/tool.py +475 -0
  317. langchain_agentx/tools/read/__init__.py +9 -0
  318. langchain_agentx/tools/read/backend.py +415 -0
  319. langchain_agentx/tools/read/limits.py +67 -0
  320. langchain_agentx/tools/read/models.py +156 -0
  321. langchain_agentx/tools/read/prompt.py +73 -0
  322. langchain_agentx/tools/read/tool.py +494 -0
  323. langchain_agentx/tools/ripgrep_plugin_exclusions.py +137 -0
  324. langchain_agentx/tools/skill/__init__.py +4 -0
  325. langchain_agentx/tools/skill/argument_substitution.py +80 -0
  326. langchain_agentx/tools/skill/loader.py +196 -0
  327. langchain_agentx/tools/skill/models.py +88 -0
  328. langchain_agentx/tools/skill/policy.py +80 -0
  329. langchain_agentx/tools/skill/prompt.py +35 -0
  330. langchain_agentx/tools/skill/tool.py +222 -0
  331. langchain_agentx/utils/__init__.py +0 -0
  332. langchain_agentx/utils/cwd.py +124 -0
  333. langchain_agentx/utils/host_platform.py +112 -0
  334. langchain_agentx/utils/path_hierarchy.py +48 -0
  335. langchain_agentx/utils/path_user_input.py +66 -0
  336. langchain_agentx/utils/rg_executable.py +18 -0
  337. langchain_agentx/utils/subprocess_text.py +101 -0
  338. langchain_agentx/utils/temp_paths.py +77 -0
  339. langchain_agentx/utils/unc_path.py +25 -0
  340. langchain_agentx/utils/win_reserved_paths.py +51 -0
  341. langchain_agentx/workflow/__init__.py +7 -0
  342. langchain_agentx/workflow/base.py +97 -0
  343. langchain_agentx/workflow/batch.py +55 -0
  344. langchain_agentx/workflow/dag.py +54 -0
  345. langchain_agentx/workspace/__init__.py +13 -0
  346. langchain_agentx/workspace/config.py +140 -0
  347. langchain_agentx/workspace/path_key_normalizer.py +30 -0
  348. langchain_agentx/workspace/resolver.py +74 -0
  349. langchain_agentx/workspace/validators.py +41 -0
  350. langchain_agentx_python-0.1.dist-info/LICENSE +201 -0
  351. langchain_agentx_python-0.1.dist-info/METADATA +513 -0
  352. langchain_agentx_python-0.1.dist-info/RECORD +354 -0
  353. langchain_agentx_python-0.1.dist-info/WHEEL +5 -0
  354. langchain_agentx_python-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1447 @@
1
+ """
2
+ tools/bash/backend.py — BashRuntimeTool 命令执行后端
3
+
4
+ 职责:
5
+ 封装所有 subprocess / asyncio 操作,与工具 hook 逻辑解耦。
6
+ 提供同步(execute)和异步(aexecute)执行接口,以及后台任务提交(submit_background)。
7
+ 模块 4 起增加可插拔 sandbox backend,使执行层与沙箱决策层解耦。
8
+
9
+ v1 实现:
10
+ - 每次调用新建 subprocess,通过 state_bridge 的 cwd 模拟会话持久化
11
+ - 后台任务使用 subprocess.Popen 在后台运行,输出写入磁盘文件
12
+ - cwd 提取:在命令末尾追加 `echo $PWD` 特殊标记提取执行后目录
13
+
14
+ v2 升级:
15
+ - 维护长期运行的 bash 进程(真正的会话持久化)
16
+ - 输出流式返回(对应 CC runShellCommand generator)
17
+
18
+ 对应 CC:
19
+ exec() via utils/Shell.ts → execute() / aexecute()
20
+ LocalShellTask / spawnShellTask() → submit_background()
21
+ resetCwdIfOutsideProject() → cwd 越界检测(v2)
22
+ SandboxManager / shouldUseSandbox() → sandbox backend + decision layer(基础版)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import os
29
+ import queue
30
+ import sys
31
+ import re
32
+ import selectors
33
+ import shlex
34
+ import signal
35
+ import subprocess
36
+ import tempfile
37
+ import threading
38
+ import time
39
+ import uuid
40
+ from collections.abc import AsyncIterator, Iterator
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+ from typing import Any, Protocol
44
+
45
+ from .cwd_reporter import (
46
+ BashCwdReporter,
47
+ bash_single_quoted,
48
+ native_path_for_bash_redirect,
49
+ read_cwd_file,
50
+ )
51
+ from .session_manager import get_global_bash_session_manager
52
+ from .sandbox_decision import BashSandboxDecision
53
+ from .shell_locator import resolve_posix_shell
54
+ from .task_runtime import BashTaskSnapshot, BashTaskStateMachine
55
+ from .windows_shell_quoting import rewrite_windows_null_redirect
56
+
57
+ # cwd 提取用的特殊 sentinel(不太可能出现在正常输出中)
58
+ _CWD_SENTINEL = "__BASH_CWD_SENTINEL_63f8a2__"
59
+
60
+
61
+ @dataclass
62
+ class ExecuteResult:
63
+ """同步/异步命令执行结果。"""
64
+
65
+ stdout: str
66
+ """命令输出(stderr 已合并)。"""
67
+
68
+ exit_code: int
69
+ """命令退出码。"""
70
+
71
+ interrupted: bool
72
+ """是否因超时或信号中断。"""
73
+
74
+ cwd_after: str | None = None
75
+ """命令执行后的工作目录(cd 命令会改变此值)。"""
76
+
77
+ sandboxed: bool = False
78
+ """是否通过 sandbox backend 执行。"""
79
+
80
+ sandbox_bypass_reason: str | None = None
81
+ """未走沙箱时的原因。"""
82
+
83
+ sandbox_temp_dir: str | None = None
84
+ """沙箱执行时注入的 TMPDIR。"""
85
+
86
+ sandbox_violation_message: str | None = None
87
+ """沙箱 backend 返回的结构化 violation 信息。"""
88
+
89
+
90
+ @dataclass
91
+ class BackgroundTask:
92
+ """后台任务描述符。"""
93
+
94
+ task_id: str
95
+ """唯一任务 ID(UUID4)。"""
96
+
97
+ output_path: str
98
+ """输出文件的绝对路径(模型可用 Read 工具读取)。"""
99
+
100
+ pid: int
101
+ """后台进程 PID。"""
102
+
103
+ sandboxed: bool = False
104
+ """后台任务是否在沙箱模式下执行。"""
105
+
106
+ sandbox_temp_dir: str | None = None
107
+ """后台任务的 TMPDIR(如适用)。"""
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class BashStreamEvent:
112
+ """前台执行流式事件。"""
113
+
114
+ kind: str
115
+ chunk: str | None = None
116
+ exit_code: int | None = None
117
+ interrupted: bool | None = None
118
+ cwd_after: str | None = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class BackgroundTaskStatus:
123
+ """后台任务状态快照。"""
124
+
125
+ task_id: str
126
+ output_path: str
127
+ pid: int
128
+ running: bool
129
+ exit_code: int | None
130
+ output_size: int
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class BackgroundTaskOutputDelta:
135
+ """后台任务输出增量。"""
136
+
137
+ task_id: str
138
+ chunk: str
139
+ next_offset: int
140
+ finished: bool
141
+ exit_code: int | None
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class SandboxExecutionRequest:
146
+ command: str
147
+ cwd: str | None
148
+ timeout_sec: int
149
+ env: dict[str, str]
150
+ decision: BashSandboxDecision
151
+ output_dir: str | None = None
152
+
153
+
154
+ class BashSandboxBackendProtocol(Protocol):
155
+ """可插拔的 Bash 沙箱后端协议。"""
156
+
157
+ def execute(self, request: SandboxExecutionRequest) -> ExecuteResult:
158
+ ...
159
+
160
+ async def aexecute(self, request: SandboxExecutionRequest) -> ExecuteResult:
161
+ ...
162
+
163
+ def submit_background(self, request: SandboxExecutionRequest) -> BackgroundTask:
164
+ ...
165
+
166
+
167
+ class LocalTmpdirSandboxBackend:
168
+ """
169
+ 基础版沙箱后端。
170
+
171
+ 当前不尝试实现完整 OS 级隔离,而是先保证:
172
+ - 每次执行使用独立 TMPDIR
173
+ - 后续可替换为真正的 sandbox backend 而不改变上层接口
174
+ """
175
+
176
+ def __init__(self, backend: "BashBackend") -> None:
177
+ self._backend = backend
178
+
179
+ def execute(self, request: SandboxExecutionRequest) -> ExecuteResult:
180
+ sandbox_env, temp_dir = self._build_env(request)
181
+ result = self._backend._execute_raw(
182
+ command=request.command,
183
+ cwd=request.cwd,
184
+ timeout_sec=request.timeout_sec,
185
+ env=sandbox_env,
186
+ )
187
+ payload = dict(result.__dict__)
188
+ payload["sandboxed"] = True
189
+ payload["sandbox_temp_dir"] = temp_dir
190
+ payload["sandbox_bypass_reason"] = None
191
+ return ExecuteResult(**payload)
192
+
193
+ async def aexecute(self, request: SandboxExecutionRequest) -> ExecuteResult:
194
+ sandbox_env, temp_dir = self._build_env(request)
195
+ result = await self._backend._aexecute_raw(
196
+ command=request.command,
197
+ cwd=request.cwd,
198
+ timeout_sec=request.timeout_sec,
199
+ env=sandbox_env,
200
+ )
201
+ payload = dict(result.__dict__)
202
+ payload["sandboxed"] = True
203
+ payload["sandbox_temp_dir"] = temp_dir
204
+ payload["sandbox_bypass_reason"] = None
205
+ return ExecuteResult(**payload)
206
+
207
+ def submit_background(self, request: SandboxExecutionRequest) -> BackgroundTask:
208
+ sandbox_env, temp_dir = self._build_env(request)
209
+ task = self._backend._submit_background_raw(
210
+ command=request.command,
211
+ cwd=request.cwd,
212
+ output_dir=request.output_dir or "~/.cache/langchain_agentx/tasks",
213
+ env=sandbox_env,
214
+ )
215
+ return BackgroundTask(
216
+ task_id=task.task_id,
217
+ output_path=task.output_path,
218
+ pid=task.pid,
219
+ sandboxed=True,
220
+ sandbox_temp_dir=temp_dir,
221
+ )
222
+
223
+ @staticmethod
224
+ def _sandbox_root(cwd: str | None) -> str:
225
+ base_dir = cwd if cwd and os.path.isdir(cwd) else tempfile.gettempdir()
226
+ root = os.path.join(base_dir, ".agentx-sandbox")
227
+ os.makedirs(root, exist_ok=True)
228
+ return root
229
+
230
+ def _build_env(self, request: SandboxExecutionRequest) -> tuple[dict[str, str], str]:
231
+ temp_dir = tempfile.mkdtemp(prefix="tmp-", dir=self._sandbox_root(request.cwd))
232
+ sandbox_env = dict(request.env)
233
+ sandbox_env["TMPDIR"] = temp_dir
234
+ sandbox_env["TMP"] = temp_dir
235
+ sandbox_env["TEMP"] = temp_dir
236
+ return sandbox_env, temp_dir
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class BashSandboxRuntimeConfig:
241
+ """
242
+ P0 约束型沙箱运行配置。
243
+
244
+ 目标不是完整替代 OS 级隔离,而是先落地两条可验证防线:
245
+ 1. 网络命令默认阻断(可配置放开)
246
+ 2. 显式写路径必须在允许根目录内
247
+ """
248
+
249
+ allow_network: bool = False
250
+ blocked_network_commands: tuple[str, ...] = (
251
+ "curl", "wget", "nc", "ncat", "netcat", "telnet", "ftp", "ssh",
252
+ )
253
+ allowed_write_roots: tuple[str, ...] = ()
254
+
255
+
256
+ class ConstrainedSandboxBackend:
257
+ """
258
+ 约束型沙箱后端(P0)。
259
+
260
+ 在 `LocalTmpdirSandboxBackend` 之上增加 command preflight:
261
+ - 阻断网络命令
262
+ - 阻断显式越界写路径
263
+
264
+ 与 CC 对齐方向:
265
+ 对应 `SandboxManager` 的“执行前约束 + violation 可解释”能力。
266
+ 当前仍是基础版,不替代后续真实 OS 级隔离实现。
267
+ """
268
+
269
+ _SEGMENT_SPLIT_RE = re.compile(r"\s*(?:&&|\|\||;|\|)\s*")
270
+ _REDIRECTION_WRITE_RE = re.compile(
271
+ r"(?:^|\s)(?:\d*>>?|\d*>\||&>)(?:\s*|)([^\s;&|]+)"
272
+ )
273
+
274
+ def __init__(
275
+ self,
276
+ delegate: LocalTmpdirSandboxBackend,
277
+ config: BashSandboxRuntimeConfig | None = None,
278
+ ) -> None:
279
+ self._delegate = delegate
280
+ self._config = config or BashSandboxRuntimeConfig()
281
+
282
+ def execute(self, request: SandboxExecutionRequest) -> ExecuteResult:
283
+ violation = self._violation_for_request(request)
284
+ if violation is not None:
285
+ return self._violation_result(violation)
286
+ return self._delegate.execute(request)
287
+
288
+ async def aexecute(self, request: SandboxExecutionRequest) -> ExecuteResult:
289
+ violation = self._violation_for_request(request)
290
+ if violation is not None:
291
+ return self._violation_result(violation)
292
+ return await self._delegate.aexecute(request)
293
+
294
+ def submit_background(self, request: SandboxExecutionRequest) -> BackgroundTask:
295
+ # P0 先沿用模块4语义,后台任务不做预检查阻断,避免改变既有回传协议。
296
+ return self._delegate.submit_background(request)
297
+
298
+ def _violation_for_request(self, request: SandboxExecutionRequest) -> str | None:
299
+ network_violation = self._check_network_violation(request.command)
300
+ if network_violation is not None:
301
+ return network_violation
302
+ return self._check_write_violation(request.command, request.cwd)
303
+
304
+ def _check_network_violation(self, command: str) -> str | None:
305
+ if self._config.allow_network:
306
+ return None
307
+ for segment in self._SEGMENT_SPLIT_RE.split(command):
308
+ executable = self._extract_executable(segment)
309
+ if not executable:
310
+ continue
311
+ if executable in self._config.blocked_network_commands:
312
+ return (
313
+ "Sandbox violation: network-restricted command detected "
314
+ f"(`{executable}`)."
315
+ )
316
+ return None
317
+
318
+ def _check_write_violation(self, command: str, cwd: str | None) -> str | None:
319
+ if not self._config.allowed_write_roots:
320
+ return None
321
+ write_paths: list[str] = []
322
+ write_paths.extend(self._extract_redirection_paths(command))
323
+ write_paths.extend(self._extract_explicit_write_paths(command))
324
+ for raw in write_paths:
325
+ resolved = self._resolve_path(raw, cwd)
326
+ if resolved is None:
327
+ continue
328
+ if not any(self._is_within_root(resolved, root) for root in self._config.allowed_write_roots):
329
+ return (
330
+ "Sandbox violation: write target outside sandbox roots "
331
+ f"(`{resolved}`)."
332
+ )
333
+ return None
334
+
335
+ def _extract_redirection_paths(self, command: str) -> list[str]:
336
+ results: list[str] = []
337
+ for match in self._REDIRECTION_WRITE_RE.finditer(command):
338
+ path = match.group(1).strip("\"'")
339
+ if path and not path.startswith("$"):
340
+ results.append(path)
341
+ return results
342
+
343
+ @staticmethod
344
+ def _extract_explicit_write_paths(command: str) -> list[str]:
345
+ try:
346
+ args = shlex.split(command)
347
+ except ValueError:
348
+ return []
349
+ if not args:
350
+ return []
351
+ base = os.path.basename(args[0])
352
+ if base in {"touch", "mkdir", "rmdir", "rm", "chmod", "chown"}:
353
+ return [a for a in args[1:] if a and not a.startswith("-")]
354
+ if base in {"cp", "mv", "install", "ln"} and len(args) >= 2:
355
+ candidates = [a for a in args[1:] if a and not a.startswith("-")]
356
+ return candidates[-1:] if candidates else []
357
+ if base == "dd":
358
+ for item in args[1:]:
359
+ if item.startswith("of="):
360
+ return [item[3:]]
361
+ return []
362
+
363
+ @staticmethod
364
+ def _extract_executable(segment: str) -> str | None:
365
+ try:
366
+ args = shlex.split(segment)
367
+ except ValueError:
368
+ return None
369
+ if not args:
370
+ return None
371
+ index = 0
372
+ while index < len(args):
373
+ token = args[index]
374
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=.*$", token):
375
+ index += 1
376
+ continue
377
+ return os.path.basename(token)
378
+ return None
379
+
380
+ @staticmethod
381
+ def _resolve_path(raw: str, cwd: str | None) -> str | None:
382
+ if not raw or raw.startswith("$"):
383
+ return None
384
+ path = os.path.expanduser(raw)
385
+ if not os.path.isabs(path):
386
+ base = cwd or os.getcwd()
387
+ path = os.path.join(base, path)
388
+ return os.path.realpath(path)
389
+
390
+ @staticmethod
391
+ def _is_within_root(path: str, root: str) -> bool:
392
+ root_real = os.path.realpath(os.path.expanduser(root))
393
+ try:
394
+ common = os.path.commonpath([path, root_real])
395
+ except ValueError:
396
+ return False
397
+ return common == root_real
398
+
399
+ @staticmethod
400
+ def _violation_result(message: str) -> ExecuteResult:
401
+ return ExecuteResult(
402
+ stdout="",
403
+ exit_code=126,
404
+ interrupted=False,
405
+ sandboxed=True,
406
+ sandbox_violation_message=message,
407
+ )
408
+
409
+
410
+ class BashBackend:
411
+ """
412
+ 命令执行 I/O 后端。
413
+
414
+ 与 BashRuntimeTool 解耦,便于在测试中 mock。
415
+ """
416
+
417
+ def __init__(
418
+ self,
419
+ sandbox_backend: BashSandboxBackendProtocol | None = None,
420
+ sandbox_runtime_config: BashSandboxRuntimeConfig | None = None,
421
+ use_persistent_session: bool | None = None,
422
+ posix_shell_executable: str | None = None,
423
+ ) -> None:
424
+ # Phase 0 冻结(exec-plan bash-runtime-tool-cross-platform):win32 上默认关闭
425
+ # 真持久会话,直至 Phase 4 冒烟;忽略 AGENTX_BASH_USE_PERSISTENT_SESSION,避免
426
+ # pass_fds/select 与 Git Bash 组合在未验证路径上被 env 误开。
427
+ if use_persistent_session is not None:
428
+ self._use_persistent_session = bool(use_persistent_session)
429
+ elif sys.platform == "win32":
430
+ self._use_persistent_session = False
431
+ else:
432
+ self._use_persistent_session = bool(
433
+ int(os.getenv("AGENTX_BASH_USE_PERSISTENT_SESSION", "0"))
434
+ )
435
+ if sandbox_backend is not None:
436
+ self._sandbox_backend = sandbox_backend
437
+ else:
438
+ base_backend = LocalTmpdirSandboxBackend(self)
439
+ self._sandbox_backend = ConstrainedSandboxBackend(
440
+ delegate=base_backend,
441
+ config=sandbox_runtime_config,
442
+ )
443
+ self._posix_shell_override = posix_shell_executable
444
+
445
+ def _posix_shell_executable(self, env: dict[str, str]) -> str:
446
+ """解析 argv[0];测试可注入 `posix_shell_executable` 跳过 PATH 探测。"""
447
+ if self._posix_shell_override is not None:
448
+ return os.path.normpath(
449
+ os.path.realpath(os.path.expanduser(self._posix_shell_override))
450
+ )
451
+ merged = os.environ.copy()
452
+ merged.update(env)
453
+ return resolve_posix_shell(environ=merged)
454
+
455
+ def _preprocess_shell_command(self, command: str) -> str:
456
+ """进入 `-c` 包装前:win32 上纠偏 CMD 风格 `nul` 重定向(见 `windows_shell_quoting`)。"""
457
+ return rewrite_windows_null_redirect(command)
458
+
459
+ @staticmethod
460
+ def _spawn_env_with_shell(env: dict[str, str], shell_exe: str) -> dict[str, str]:
461
+ """子进程 env:对齐 CC 思路,在未显式设置时注入 `SHELL`。"""
462
+ out = dict(env)
463
+ out.setdefault("SHELL", shell_exe)
464
+ return out
465
+
466
+ @staticmethod
467
+ def _win32_creationflags() -> int:
468
+ """可选隐藏控制台:`AGENTX_BASH_CREATE_NO_WINDOW=1`(默认关闭,兼容性风险见 exec-plan)。"""
469
+ if sys.platform != "win32":
470
+ return 0
471
+ if os.environ.get("AGENTX_BASH_CREATE_NO_WINDOW") != "1":
472
+ return 0
473
+ return int(getattr(subprocess, "CREATE_NO_WINDOW", 0))
474
+
475
+ def execute(
476
+ self,
477
+ command: str,
478
+ cwd: str | None = None,
479
+ timeout_sec: int = 120,
480
+ env: dict[str, str] | None = None,
481
+ sandbox_decision: BashSandboxDecision | None = None,
482
+ session_key: str | None = None,
483
+ ) -> ExecuteResult:
484
+ """
485
+ 同步执行 Shell 命令。
486
+
487
+ - cwd:工作目录(来自 state_bridge)
488
+ - stderr 合并到 stdout(与 CC merged fd 等价)
489
+ - 在命令末尾注入 cwd 提取指令
490
+ """
491
+ effective_env = os.environ.copy()
492
+ if env:
493
+ effective_env.update(env)
494
+
495
+ decision = sandbox_decision or BashSandboxDecision(
496
+ use_sandbox=False,
497
+ reason="sandbox_not_requested",
498
+ )
499
+ if decision.use_sandbox:
500
+ return self._sandbox_backend.execute(
501
+ SandboxExecutionRequest(
502
+ command=command,
503
+ cwd=cwd,
504
+ timeout_sec=timeout_sec,
505
+ env=effective_env,
506
+ decision=decision,
507
+ )
508
+ )
509
+
510
+ if self._use_persistent_session and session_key:
511
+ result = self._execute_via_session(
512
+ command=command,
513
+ cwd=cwd,
514
+ timeout_sec=timeout_sec,
515
+ env=effective_env,
516
+ session_key=session_key,
517
+ )
518
+ result.sandbox_bypass_reason = decision.reason
519
+ return result
520
+
521
+ result = self._execute_raw(
522
+ command=command,
523
+ cwd=cwd,
524
+ timeout_sec=timeout_sec,
525
+ env=effective_env,
526
+ )
527
+ result.sandbox_bypass_reason = decision.reason
528
+ return result
529
+
530
+ def _execute_raw(
531
+ self,
532
+ *,
533
+ command: str,
534
+ cwd: str | None,
535
+ timeout_sec: int,
536
+ env: dict[str, str],
537
+ ) -> ExecuteResult:
538
+ # cwd 提取:Unix = fd3 + pass_fds(与 Phase 0 冻结一致);win32 = 临时文件 + pwd -P
539
+ # (见 `cwd_reporter.py` / bash-tool-cross-platform Phase 2)。
540
+ import subprocess as _sp
541
+
542
+ shell_exe = self._posix_shell_executable(env)
543
+ command = self._preprocess_shell_command(command)
544
+ if sys.platform == "win32":
545
+ return self._execute_raw_win32(
546
+ command=command,
547
+ cwd=cwd,
548
+ timeout_sec=timeout_sec,
549
+ env=env,
550
+ shell_exe=shell_exe,
551
+ )
552
+
553
+ read_fd, write_fd = os.pipe()
554
+ wrapped = BashCwdReporter.wrap_with_fd3(command, write_fd)
555
+
556
+ try:
557
+ proc = _sp.Popen(
558
+ [shell_exe, "-c", wrapped],
559
+ stdout=_sp.PIPE,
560
+ stderr=_sp.STDOUT,
561
+ cwd=cwd,
562
+ env=self._spawn_env_with_shell(env, shell_exe),
563
+ pass_fds=(write_fd,),
564
+ )
565
+ os.close(write_fd)
566
+ write_fd = -1
567
+
568
+ try:
569
+ stdout_bytes, _ = proc.communicate(timeout=timeout_sec)
570
+ except _sp.TimeoutExpired:
571
+ proc.kill()
572
+ stdout_bytes, _ = proc.communicate()
573
+ cwd_after = _read_fd_safe(read_fd)
574
+ os.close(read_fd)
575
+ partial = stdout_bytes.decode("utf-8", errors="replace")
576
+ return ExecuteResult(
577
+ stdout=partial,
578
+ exit_code=-1,
579
+ interrupted=True,
580
+ cwd_after=cwd_after,
581
+ )
582
+
583
+ cwd_after = _read_fd_safe(read_fd)
584
+ os.close(read_fd)
585
+ read_fd = -1
586
+
587
+ stdout_str = stdout_bytes.decode("utf-8", errors="replace")
588
+ return ExecuteResult(
589
+ stdout=stdout_str,
590
+ exit_code=proc.returncode,
591
+ interrupted=False,
592
+ cwd_after=cwd_after,
593
+ )
594
+ except FileNotFoundError:
595
+ return ExecuteResult(
596
+ stdout="bash: command not found",
597
+ exit_code=127,
598
+ interrupted=False,
599
+ )
600
+ finally:
601
+ if write_fd != -1:
602
+ try:
603
+ os.close(write_fd)
604
+ except OSError:
605
+ pass
606
+ if read_fd != -1:
607
+ try:
608
+ os.close(read_fd)
609
+ except OSError:
610
+ pass
611
+
612
+ def _execute_raw_win32(
613
+ self,
614
+ *,
615
+ command: str,
616
+ cwd: str | None,
617
+ timeout_sec: int,
618
+ env: dict[str, str],
619
+ shell_exe: str,
620
+ ) -> ExecuteResult:
621
+ fd, cwd_file = tempfile.mkstemp(prefix="agentx_bash_cwd_", suffix=".txt")
622
+ os.close(fd)
623
+ posix_tgt = native_path_for_bash_redirect(cwd_file)
624
+ wrapped = BashCwdReporter.wrap_with_cwd_file(
625
+ command, bash_single_quoted(posix_tgt)
626
+ )
627
+ spawn_env = self._spawn_env_with_shell(env, shell_exe)
628
+ _cf = self._win32_creationflags()
629
+ try:
630
+ if _cf:
631
+ proc = subprocess.Popen(
632
+ [shell_exe, "-c", wrapped],
633
+ stdout=subprocess.PIPE,
634
+ stderr=subprocess.STDOUT,
635
+ cwd=cwd,
636
+ env=spawn_env,
637
+ creationflags=_cf,
638
+ )
639
+ else:
640
+ proc = subprocess.Popen(
641
+ [shell_exe, "-c", wrapped],
642
+ stdout=subprocess.PIPE,
643
+ stderr=subprocess.STDOUT,
644
+ cwd=cwd,
645
+ env=spawn_env,
646
+ )
647
+ try:
648
+ stdout_bytes, _ = proc.communicate(timeout=timeout_sec)
649
+ except subprocess.TimeoutExpired:
650
+ proc.kill()
651
+ stdout_bytes, _ = proc.communicate()
652
+ cwd_after = read_cwd_file(cwd_file)
653
+ partial = stdout_bytes.decode("utf-8", errors="replace")
654
+ return ExecuteResult(
655
+ stdout=partial,
656
+ exit_code=-1,
657
+ interrupted=True,
658
+ cwd_after=cwd_after,
659
+ )
660
+
661
+ cwd_after = read_cwd_file(cwd_file)
662
+ stdout_str = stdout_bytes.decode("utf-8", errors="replace")
663
+ return ExecuteResult(
664
+ stdout=stdout_str,
665
+ exit_code=proc.returncode,
666
+ interrupted=False,
667
+ cwd_after=cwd_after,
668
+ )
669
+ except FileNotFoundError:
670
+ return ExecuteResult(
671
+ stdout="bash: command not found",
672
+ exit_code=127,
673
+ interrupted=False,
674
+ )
675
+ finally:
676
+ try:
677
+ os.unlink(cwd_file)
678
+ except OSError:
679
+ pass
680
+
681
+ async def aexecute(
682
+ self,
683
+ command: str,
684
+ cwd: str | None = None,
685
+ timeout_sec: int = 120,
686
+ env: dict[str, str] | None = None,
687
+ sandbox_decision: BashSandboxDecision | None = None,
688
+ session_key: str | None = None,
689
+ ) -> ExecuteResult:
690
+ """
691
+ 异步执行 Shell 命令(asyncio.subprocess)。
692
+
693
+ 行为与 execute() 等价,但不阻塞事件循环。
694
+ cwd 提取:Unix 为 fd3 + pass_fds;win32 为临时文件 + pwd -P(与 `_execute_raw` 一致)。
695
+ """
696
+ effective_env = os.environ.copy()
697
+ if env:
698
+ effective_env.update(env)
699
+
700
+ decision = sandbox_decision or BashSandboxDecision(
701
+ use_sandbox=False,
702
+ reason="sandbox_not_requested",
703
+ )
704
+ if decision.use_sandbox:
705
+ return await self._sandbox_backend.aexecute(
706
+ SandboxExecutionRequest(
707
+ command=command,
708
+ cwd=cwd,
709
+ timeout_sec=timeout_sec,
710
+ env=effective_env,
711
+ decision=decision,
712
+ )
713
+ )
714
+
715
+ if self._use_persistent_session and session_key:
716
+ loop = asyncio.get_event_loop()
717
+ result = await loop.run_in_executor(
718
+ None,
719
+ lambda: self._execute_via_session(
720
+ command=command,
721
+ cwd=cwd,
722
+ timeout_sec=timeout_sec,
723
+ env=effective_env,
724
+ session_key=session_key,
725
+ ),
726
+ )
727
+ else:
728
+ result = await self._aexecute_raw(
729
+ command=command,
730
+ cwd=cwd,
731
+ timeout_sec=timeout_sec,
732
+ env=effective_env,
733
+ )
734
+ result.sandbox_bypass_reason = decision.reason
735
+ return result
736
+
737
+ async def _aexecute_raw(
738
+ self,
739
+ *,
740
+ command: str,
741
+ cwd: str | None,
742
+ timeout_sec: int,
743
+ env: dict[str, str],
744
+ ) -> ExecuteResult:
745
+ shell_exe = self._posix_shell_executable(env)
746
+ command = self._preprocess_shell_command(command)
747
+ if sys.platform == "win32":
748
+ return await self._aexecute_raw_win32(
749
+ command=command,
750
+ cwd=cwd,
751
+ timeout_sec=timeout_sec,
752
+ env=env,
753
+ shell_exe=shell_exe,
754
+ )
755
+
756
+ read_fd, write_fd = os.pipe()
757
+ wrapped = BashCwdReporter.wrap_with_fd3(command, write_fd)
758
+
759
+ try:
760
+ proc = await asyncio.create_subprocess_exec(
761
+ shell_exe,
762
+ "-c",
763
+ wrapped,
764
+ stdout=asyncio.subprocess.PIPE,
765
+ stderr=asyncio.subprocess.STDOUT,
766
+ cwd=cwd,
767
+ env=self._spawn_env_with_shell(env, shell_exe),
768
+ pass_fds=(write_fd,),
769
+ )
770
+ os.close(write_fd)
771
+ write_fd = -1
772
+
773
+ try:
774
+ stdout_bytes, _ = await asyncio.wait_for(
775
+ proc.communicate(),
776
+ timeout=timeout_sec,
777
+ )
778
+ except asyncio.TimeoutError:
779
+ proc.kill()
780
+ await proc.wait()
781
+ cwd_after = _read_fd_safe(read_fd)
782
+ os.close(read_fd)
783
+ read_fd = -1
784
+ return ExecuteResult(
785
+ stdout="",
786
+ exit_code=-1,
787
+ interrupted=True,
788
+ cwd_after=cwd_after,
789
+ )
790
+
791
+ cwd_after = _read_fd_safe(read_fd)
792
+ os.close(read_fd)
793
+ read_fd = -1
794
+
795
+ stdout_str = stdout_bytes.decode("utf-8", errors="replace")
796
+ return ExecuteResult(
797
+ stdout=stdout_str,
798
+ exit_code=proc.returncode or 0,
799
+ interrupted=False,
800
+ cwd_after=cwd_after,
801
+ )
802
+ except FileNotFoundError:
803
+ return ExecuteResult(
804
+ stdout="bash: command not found",
805
+ exit_code=127,
806
+ interrupted=False,
807
+ )
808
+ finally:
809
+ if write_fd != -1:
810
+ try:
811
+ os.close(write_fd)
812
+ except OSError:
813
+ pass
814
+ if read_fd != -1:
815
+ try:
816
+ os.close(read_fd)
817
+ except OSError:
818
+ pass
819
+
820
+ async def _aexecute_raw_win32(
821
+ self,
822
+ *,
823
+ command: str,
824
+ cwd: str | None,
825
+ timeout_sec: int,
826
+ env: dict[str, str],
827
+ shell_exe: str,
828
+ ) -> ExecuteResult:
829
+ fd, cwd_file = tempfile.mkstemp(prefix="agentx_bash_cwd_", suffix=".txt")
830
+ os.close(fd)
831
+ posix_tgt = native_path_for_bash_redirect(cwd_file)
832
+ wrapped = BashCwdReporter.wrap_with_cwd_file(
833
+ command, bash_single_quoted(posix_tgt)
834
+ )
835
+ spawn_env = self._spawn_env_with_shell(env, shell_exe)
836
+ _cf = self._win32_creationflags()
837
+ try:
838
+ if _cf:
839
+ proc = await asyncio.create_subprocess_exec(
840
+ shell_exe,
841
+ "-c",
842
+ wrapped,
843
+ stdout=asyncio.subprocess.PIPE,
844
+ stderr=asyncio.subprocess.STDOUT,
845
+ cwd=cwd,
846
+ env=spawn_env,
847
+ creationflags=_cf,
848
+ )
849
+ else:
850
+ proc = await asyncio.create_subprocess_exec(
851
+ shell_exe,
852
+ "-c",
853
+ wrapped,
854
+ stdout=asyncio.subprocess.PIPE,
855
+ stderr=asyncio.subprocess.STDOUT,
856
+ cwd=cwd,
857
+ env=spawn_env,
858
+ )
859
+ try:
860
+ stdout_bytes, _ = await asyncio.wait_for(
861
+ proc.communicate(),
862
+ timeout=timeout_sec,
863
+ )
864
+ except asyncio.TimeoutError:
865
+ proc.kill()
866
+ await proc.wait()
867
+ cwd_after = read_cwd_file(cwd_file)
868
+ return ExecuteResult(
869
+ stdout="",
870
+ exit_code=-1,
871
+ interrupted=True,
872
+ cwd_after=cwd_after,
873
+ )
874
+
875
+ cwd_after = read_cwd_file(cwd_file)
876
+ stdout_str = (stdout_bytes or b"").decode("utf-8", errors="replace")
877
+ return ExecuteResult(
878
+ stdout=stdout_str,
879
+ exit_code=proc.returncode or 0,
880
+ interrupted=False,
881
+ cwd_after=cwd_after,
882
+ )
883
+ except FileNotFoundError:
884
+ return ExecuteResult(
885
+ stdout="bash: command not found",
886
+ exit_code=127,
887
+ interrupted=False,
888
+ )
889
+ finally:
890
+ try:
891
+ os.unlink(cwd_file)
892
+ except OSError:
893
+ pass
894
+
895
+ def submit_background(
896
+ self,
897
+ command: str,
898
+ cwd: str | None = None,
899
+ output_dir: str = "~/.cache/langchain_agentx/tasks",
900
+ env: dict[str, str] | None = None,
901
+ sandbox_decision: BashSandboxDecision | None = None,
902
+ ) -> BackgroundTask:
903
+ """
904
+ 提交后台任务,立即返回 BackgroundTask(不等待完成)。
905
+
906
+ 输出写入磁盘文件,模型可通过 Read 工具读取。
907
+ 对应 CC spawnShellTask() + LocalShellTask。
908
+ """
909
+ effective_env = os.environ.copy()
910
+ if env:
911
+ effective_env.update(env)
912
+
913
+ decision = sandbox_decision or BashSandboxDecision(
914
+ use_sandbox=False,
915
+ reason="sandbox_not_requested",
916
+ )
917
+ if decision.use_sandbox:
918
+ return self._sandbox_backend.submit_background(
919
+ SandboxExecutionRequest(
920
+ command=command,
921
+ cwd=cwd,
922
+ timeout_sec=0,
923
+ env=effective_env,
924
+ decision=decision,
925
+ output_dir=output_dir,
926
+ )
927
+ )
928
+
929
+ return self._submit_background_raw(
930
+ command=command,
931
+ cwd=cwd,
932
+ output_dir=output_dir,
933
+ env=effective_env,
934
+ )
935
+
936
+ def stream_execute(
937
+ self,
938
+ command: str,
939
+ cwd: str | None = None,
940
+ timeout_sec: int = 120,
941
+ env: dict[str, str] | None = None,
942
+ session_key: str | None = None,
943
+ ) -> list[BashStreamEvent]:
944
+ """
945
+ P1 基础版:前台流式执行接口。
946
+
947
+ 当前返回离散事件列表(chunk/final),后续可升级为 async generator。
948
+ """
949
+ return list(
950
+ self.stream_execute_iter(
951
+ command=command,
952
+ cwd=cwd,
953
+ timeout_sec=timeout_sec,
954
+ env=env,
955
+ session_key=session_key,
956
+ )
957
+ )
958
+
959
+ def stream_execute_iter(
960
+ self,
961
+ command: str,
962
+ cwd: str | None = None,
963
+ timeout_sec: int = 120,
964
+ env: dict[str, str] | None = None,
965
+ session_key: str | None = None,
966
+ cancel_event: threading.Event | None = None,
967
+ sandbox_decision: BashSandboxDecision | None = None,
968
+ ) -> Iterator[BashStreamEvent]:
969
+ """
970
+ P1 完整版:前台实时流式执行迭代器。
971
+ """
972
+ effective_env = os.environ.copy()
973
+ if env:
974
+ effective_env.update(env)
975
+ if self._use_persistent_session and session_key:
976
+ manager = get_global_bash_session_manager()
977
+ runtime = manager.get_or_create(session_key=session_key, cwd=cwd, env=effective_env)
978
+ for ev in runtime.execute_stream_iter(
979
+ command=self._preprocess_shell_command(command),
980
+ timeout_sec=timeout_sec,
981
+ cancel_event=cancel_event,
982
+ ):
983
+ yield BashStreamEvent(
984
+ kind=ev.kind,
985
+ chunk=ev.chunk,
986
+ exit_code=ev.exit_code,
987
+ interrupted=ev.interrupted,
988
+ cwd_after=ev.cwd_after,
989
+ )
990
+ return
991
+ decision = sandbox_decision or BashSandboxDecision(
992
+ use_sandbox=False,
993
+ reason="sandbox_not_requested",
994
+ )
995
+ if decision.use_sandbox:
996
+ result = self.execute(
997
+ command=command,
998
+ cwd=cwd,
999
+ timeout_sec=timeout_sec,
1000
+ env=effective_env,
1001
+ session_key=session_key,
1002
+ sandbox_decision=decision,
1003
+ )
1004
+ if result.stdout:
1005
+ yield BashStreamEvent(kind="chunk", chunk=result.stdout)
1006
+ yield BashStreamEvent(
1007
+ kind="final",
1008
+ exit_code=result.exit_code,
1009
+ interrupted=result.interrupted,
1010
+ cwd_after=result.cwd_after,
1011
+ )
1012
+ return
1013
+
1014
+ wrapped = self._build_stream_wrapper(self._preprocess_shell_command(command))
1015
+ yield from self._stream_execute_raw_iter(
1016
+ wrapped_command=wrapped,
1017
+ cwd=cwd,
1018
+ timeout_sec=timeout_sec,
1019
+ env=effective_env,
1020
+ cancel_event=cancel_event,
1021
+ )
1022
+
1023
+ @staticmethod
1024
+ def _build_stream_wrapper(command: str) -> str:
1025
+ marker = "__AGENTX_STREAM_CWD__"
1026
+ return (
1027
+ f"{command}\n"
1028
+ "__EC=$?\n"
1029
+ f'printf "\\n{marker}%s\\n" "$PWD"\n'
1030
+ "exit $__EC"
1031
+ )
1032
+
1033
+ def _stream_execute_raw_iter(
1034
+ self,
1035
+ *,
1036
+ wrapped_command: str,
1037
+ cwd: str | None,
1038
+ timeout_sec: int,
1039
+ env: dict[str, str],
1040
+ cancel_event: threading.Event | None,
1041
+ ) -> Iterator[BashStreamEvent]:
1042
+ marker = "__AGENTX_STREAM_CWD__"
1043
+ shell_exe = self._posix_shell_executable(env)
1044
+ spawn_env = self._spawn_env_with_shell(env, shell_exe)
1045
+ _cf = self._win32_creationflags()
1046
+ if _cf:
1047
+ proc = subprocess.Popen(
1048
+ [shell_exe, "-c", wrapped_command],
1049
+ stdout=subprocess.PIPE,
1050
+ stderr=subprocess.STDOUT,
1051
+ cwd=cwd,
1052
+ env=spawn_env,
1053
+ text=False,
1054
+ bufsize=0,
1055
+ creationflags=_cf,
1056
+ )
1057
+ else:
1058
+ proc = subprocess.Popen(
1059
+ [shell_exe, "-c", wrapped_command],
1060
+ stdout=subprocess.PIPE,
1061
+ stderr=subprocess.STDOUT,
1062
+ cwd=cwd,
1063
+ env=spawn_env,
1064
+ text=False,
1065
+ bufsize=0,
1066
+ )
1067
+ selector = selectors.DefaultSelector()
1068
+ assert proc.stdout is not None
1069
+ selector.register(proc.stdout, selectors.EVENT_READ)
1070
+
1071
+ started = time.time()
1072
+ interrupted = False
1073
+ chunks: list[str] = []
1074
+ try:
1075
+ while True:
1076
+ if cancel_event is not None and cancel_event.is_set():
1077
+ interrupted = True
1078
+ proc.kill()
1079
+ break
1080
+ if time.time() - started > timeout_sec:
1081
+ interrupted = True
1082
+ proc.kill()
1083
+ break
1084
+ if proc.poll() is not None and not selector.get_map():
1085
+ break
1086
+ events = selector.select(timeout=0.1)
1087
+ if not events:
1088
+ if proc.poll() is not None:
1089
+ break
1090
+ continue
1091
+ for key, _ in events:
1092
+ raw = key.fileobj.read1(4096) if hasattr(key.fileobj, "read1") else key.fileobj.read(4096)
1093
+ if not raw:
1094
+ selector.unregister(key.fileobj)
1095
+ continue
1096
+ text = raw.decode("utf-8", errors="replace")
1097
+ chunks.append(text)
1098
+ yield BashStreamEvent(kind="chunk", chunk=text)
1099
+ proc.wait(timeout=1)
1100
+ finally:
1101
+ selector.close()
1102
+ if proc.poll() is None:
1103
+ proc.kill()
1104
+
1105
+ merged = "".join(chunks)
1106
+ cwd_after = None
1107
+ if marker in merged:
1108
+ before, _, after = merged.rpartition(marker)
1109
+ # 末尾的 cwd 标记不再作为 chunk 内容处理
1110
+ cleaned = before
1111
+ cwd_after = after.strip().splitlines()[0] if after.strip() else None
1112
+ if cleaned != merged:
1113
+ # 让上游获得去 marker 后的稳定输出
1114
+ pass
1115
+ exit_code = proc.returncode if proc.returncode is not None else (-1 if interrupted else 0)
1116
+ yield BashStreamEvent(
1117
+ kind="final",
1118
+ exit_code=exit_code,
1119
+ interrupted=interrupted,
1120
+ cwd_after=cwd_after,
1121
+ )
1122
+
1123
+ async def astream_execute_iter(
1124
+ self,
1125
+ command: str,
1126
+ cwd: str | None = None,
1127
+ timeout_sec: int = 120,
1128
+ env: dict[str, str] | None = None,
1129
+ session_key: str | None = None,
1130
+ cancel_event: threading.Event | None = None,
1131
+ ) -> AsyncIterator[BashStreamEvent]:
1132
+ """
1133
+ 异步流式执行:在后台线程中驱动同步迭代器,主协程逐块消费(对齐 CC generator 体验)。
1134
+ """
1135
+ sync_q: queue.Queue[BashStreamEvent | None] = queue.Queue()
1136
+
1137
+ def producer() -> None:
1138
+ try:
1139
+ for ev in self.stream_execute_iter(
1140
+ command=command,
1141
+ cwd=cwd,
1142
+ timeout_sec=timeout_sec,
1143
+ env=env,
1144
+ session_key=session_key,
1145
+ cancel_event=cancel_event,
1146
+ ):
1147
+ sync_q.put(ev)
1148
+ finally:
1149
+ sync_q.put(None)
1150
+
1151
+ threading.Thread(target=producer, name="bash-stream-producer", daemon=True).start()
1152
+ while True:
1153
+ ev = await asyncio.to_thread(sync_q.get)
1154
+ if ev is None:
1155
+ break
1156
+ yield ev
1157
+
1158
+ def _execute_via_session(
1159
+ self,
1160
+ *,
1161
+ command: str,
1162
+ cwd: str | None,
1163
+ timeout_sec: int,
1164
+ env: dict[str, str],
1165
+ session_key: str,
1166
+ ) -> ExecuteResult:
1167
+ command = self._preprocess_shell_command(command)
1168
+ manager = get_global_bash_session_manager()
1169
+ runtime = manager.get_or_create(session_key=session_key, cwd=cwd, env=env)
1170
+ session_result = runtime.execute(command=command, timeout_sec=timeout_sec)
1171
+ return ExecuteResult(
1172
+ stdout=session_result.stdout,
1173
+ exit_code=session_result.exit_code,
1174
+ interrupted=session_result.interrupted,
1175
+ cwd_after=session_result.cwd_after,
1176
+ )
1177
+
1178
+ def _submit_background_raw(
1179
+ self,
1180
+ *,
1181
+ command: str,
1182
+ cwd: str | None,
1183
+ output_dir: str,
1184
+ env: dict[str, str],
1185
+ ) -> BackgroundTask:
1186
+ task_id = str(uuid.uuid4())
1187
+ output_dir_expanded = os.path.expanduser(output_dir)
1188
+ task_dir = os.path.join(output_dir_expanded, task_id)
1189
+ os.makedirs(task_dir, exist_ok=True)
1190
+ output_path = os.path.join(task_dir, "output.txt")
1191
+
1192
+ output_file = open(output_path, "wb") # noqa: WPS515
1193
+ exit_code_path = os.path.join(task_dir, "exit_code")
1194
+
1195
+ command = self._preprocess_shell_command(command)
1196
+ quoted_exit_code_path = shlex.quote(exit_code_path)
1197
+ wrapped = (
1198
+ "{ "
1199
+ f"{command}; "
1200
+ "__AGENTX_EC=$?; "
1201
+ f"printf '%s\\n' \"$__AGENTX_EC\" > {quoted_exit_code_path}; "
1202
+ "exit $__AGENTX_EC; "
1203
+ "}"
1204
+ )
1205
+ shell_exe = self._posix_shell_executable(env)
1206
+ spawn_env = self._spawn_env_with_shell(env, shell_exe)
1207
+ _cf = self._win32_creationflags()
1208
+ if _cf:
1209
+ proc = subprocess.Popen(
1210
+ [shell_exe, "-c", wrapped],
1211
+ stdout=output_file,
1212
+ stderr=subprocess.STDOUT,
1213
+ cwd=cwd,
1214
+ env=spawn_env,
1215
+ start_new_session=True,
1216
+ creationflags=_cf,
1217
+ )
1218
+ else:
1219
+ proc = subprocess.Popen(
1220
+ [shell_exe, "-c", wrapped],
1221
+ stdout=output_file,
1222
+ stderr=subprocess.STDOUT,
1223
+ cwd=cwd,
1224
+ env=spawn_env,
1225
+ start_new_session=True,
1226
+ )
1227
+
1228
+ pid_path = os.path.join(task_dir, "pid")
1229
+ with open(pid_path, "w") as f:
1230
+ f.write(str(proc.pid))
1231
+
1232
+ output_file.close()
1233
+
1234
+ return BackgroundTask(
1235
+ task_id=task_id,
1236
+ output_path=output_path,
1237
+ pid=proc.pid,
1238
+ )
1239
+
1240
+ def get_background_status(self, task: BackgroundTask) -> BackgroundTaskStatus:
1241
+ task_dir = os.path.dirname(task.output_path)
1242
+ exit_code_path = os.path.join(task_dir, "exit_code")
1243
+ output_size = os.path.getsize(task.output_path) if os.path.exists(task.output_path) else 0
1244
+ running = _is_process_alive(task.pid)
1245
+ exit_code: int | None = None
1246
+ if os.path.exists(exit_code_path):
1247
+ try:
1248
+ with open(exit_code_path, "r", encoding="utf-8") as f:
1249
+ raw = f.read().strip()
1250
+ if raw:
1251
+ exit_code = int(raw)
1252
+ except (OSError, ValueError):
1253
+ exit_code = None
1254
+ if exit_code is not None:
1255
+ running = False
1256
+ return BackgroundTaskStatus(
1257
+ task_id=task.task_id,
1258
+ output_path=task.output_path,
1259
+ pid=task.pid,
1260
+ running=running,
1261
+ exit_code=exit_code,
1262
+ output_size=output_size,
1263
+ )
1264
+
1265
+ def read_background_output_delta(
1266
+ self,
1267
+ task: BackgroundTask,
1268
+ *,
1269
+ offset: int = 0,
1270
+ ) -> BackgroundTaskOutputDelta:
1271
+ status = self.get_background_status(task)
1272
+ if not os.path.exists(task.output_path):
1273
+ return BackgroundTaskOutputDelta(
1274
+ task_id=task.task_id,
1275
+ chunk="",
1276
+ next_offset=offset,
1277
+ finished=(not status.running and status.exit_code is not None),
1278
+ exit_code=status.exit_code,
1279
+ )
1280
+ with open(task.output_path, "rb") as f:
1281
+ f.seek(max(0, offset))
1282
+ raw = f.read()
1283
+ chunk = raw.decode("utf-8", errors="replace")
1284
+ next_offset = max(0, offset) + len(raw)
1285
+ finished = (not status.running) and (status.exit_code is not None)
1286
+ return BackgroundTaskOutputDelta(
1287
+ task_id=task.task_id,
1288
+ chunk=chunk,
1289
+ next_offset=next_offset,
1290
+ finished=finished,
1291
+ exit_code=status.exit_code,
1292
+ )
1293
+
1294
+ @staticmethod
1295
+ def build_foreground_snapshot(result: ExecuteResult) -> BashTaskSnapshot:
1296
+ t0 = time.time()
1297
+ sm = BashTaskStateMachine(task_id="foreground", task_mode="foreground")
1298
+ sm.created_ts = t0
1299
+ sm.transition_to("running")
1300
+ sm.started_ts = time.time()
1301
+ if result.interrupted:
1302
+ sm.transition_to("interrupted")
1303
+ elif result.exit_code == 0:
1304
+ sm.transition_to("completed")
1305
+ else:
1306
+ sm.transition_to("failed")
1307
+ sm.ended_ts = time.time()
1308
+ sm.exit_code = result.exit_code
1309
+ return sm.to_snapshot()
1310
+
1311
+ def build_background_snapshot(self, task: BackgroundTask) -> BashTaskSnapshot:
1312
+ status = self.get_background_status(task)
1313
+ sm = BashTaskStateMachine(task_id=task.task_id, task_mode="background")
1314
+ sm.created_ts = time.time()
1315
+ sm.output_path = status.output_path
1316
+ sm.pid = status.pid
1317
+ sm.output_size = status.output_size
1318
+ sm.transition_to("running")
1319
+ sm.started_ts = time.time()
1320
+ if status.running:
1321
+ return sm.to_snapshot()
1322
+ sm.ended_ts = time.time()
1323
+ if status.exit_code == -2:
1324
+ sm.transition_to("cancelled")
1325
+ elif status.exit_code == 0:
1326
+ sm.transition_to("completed")
1327
+ elif status.exit_code is None:
1328
+ sm.transition_to("interrupted")
1329
+ else:
1330
+ sm.transition_to("failed")
1331
+ sm.exit_code = status.exit_code
1332
+ return sm.to_snapshot()
1333
+
1334
+ def cancel_background_task(self, task: BackgroundTask) -> bool:
1335
+ """
1336
+ 取消后台任务:发信号终止进程,并标记为 cancelled(exit_code 文件写 -2)。
1337
+ """
1338
+ task_dir = os.path.dirname(task.output_path)
1339
+ exit_code_path = os.path.join(task_dir, "exit_code")
1340
+ cancelled_path = os.path.join(task_dir, "cancelled")
1341
+ try:
1342
+ with open(cancelled_path, "w", encoding="utf-8") as f:
1343
+ f.write("1")
1344
+ except OSError:
1345
+ pass
1346
+ if _is_process_alive(task.pid):
1347
+ try:
1348
+ os.killpg(task.pid, signal.SIGTERM)
1349
+ except OSError:
1350
+ try:
1351
+ os.kill(task.pid, signal.SIGTERM)
1352
+ except OSError:
1353
+ pass
1354
+ time.sleep(0.05)
1355
+ if _is_process_alive(task.pid):
1356
+ try:
1357
+ os.killpg(task.pid, signal.SIGKILL)
1358
+ except OSError:
1359
+ try:
1360
+ os.kill(task.pid, signal.SIGKILL)
1361
+ except OSError:
1362
+ pass
1363
+ try:
1364
+ with open(exit_code_path, "w", encoding="utf-8") as f:
1365
+ f.write("-2\n")
1366
+ except OSError:
1367
+ return False
1368
+ return True
1369
+
1370
+
1371
+ # ---------------------------------------------------------------------------
1372
+ # 辅助函数
1373
+ # ---------------------------------------------------------------------------
1374
+
1375
+
1376
+ def _read_fd_safe(fd: int) -> str | None:
1377
+ """
1378
+ 从 fd 中读取 cwd 字符串(由子进程写入),安全处理 EOF/错误。
1379
+ 返回去掉首尾空白的路径字符串,或 None(读取失败/空)。
1380
+ """
1381
+ try:
1382
+ data = b""
1383
+ while True:
1384
+ chunk = os.read(fd, 4096)
1385
+ if not chunk:
1386
+ break
1387
+ data += chunk
1388
+ result = data.decode("utf-8", errors="replace").strip()
1389
+ return result if result else None
1390
+ except OSError:
1391
+ return None
1392
+
1393
+
1394
+ def _is_process_alive(pid: int) -> bool:
1395
+ """判断进程是否存活。"""
1396
+ try:
1397
+ os.kill(pid, 0)
1398
+ return True
1399
+ except OSError:
1400
+ return False
1401
+
1402
+
1403
+ def _extract_cwd_from_output(raw_output: str) -> tuple[str, str | None]:
1404
+ """
1405
+ 兼容旧接口:从命令输出中提取 cwd sentinel 行(已废弃,保留供测试引用)。
1406
+ 新代码使用 fd-3 策略,此函数仅用于测试模块中的直接调用。
1407
+ """
1408
+ lines = raw_output.splitlines(keepends=True)
1409
+ cwd_after: str | None = None
1410
+ clean_lines = []
1411
+
1412
+ for line in lines:
1413
+ stripped = line.rstrip("\n").rstrip("\r")
1414
+ if stripped.startswith(_CWD_SENTINEL):
1415
+ cwd_after = stripped[len(_CWD_SENTINEL):]
1416
+ else:
1417
+ clean_lines.append(line)
1418
+
1419
+ return "".join(clean_lines), cwd_after
1420
+
1421
+
1422
+ def strip_empty_lines(text: str) -> str:
1423
+ """
1424
+ 压缩连续空行为单个空行,并去除首尾空白行。
1425
+ 对应 CC utils.ts::stripEmptyLines()。
1426
+ """
1427
+ if not text:
1428
+ return text
1429
+
1430
+ # 去除首部连续空白行(对应 CC `replace(/^(\s*\n)+/, '')`)
1431
+ lines = text.splitlines(keepends=True)
1432
+ start = 0
1433
+ while start < len(lines) and lines[start].strip() == "":
1434
+ start += 1
1435
+
1436
+ # 压缩中间连续空行
1437
+ result = []
1438
+ prev_empty = False
1439
+ for line in lines[start:]:
1440
+ is_empty = line.strip() == ""
1441
+ if is_empty and prev_empty:
1442
+ continue # 跳过连续空行
1443
+ result.append(line)
1444
+ prev_empty = is_empty
1445
+
1446
+ # 去除末尾空白
1447
+ return "".join(result).rstrip()