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.
- langchain_agentx/__init__.py +46 -0
- langchain_agentx/command/__init__.py +28 -0
- langchain_agentx/command/builtin/__init__.py +25 -0
- langchain_agentx/command/builtin/clear.py +33 -0
- langchain_agentx/command/builtin/compact.py +33 -0
- langchain_agentx/command/builtin/memory.py +37 -0
- langchain_agentx/command/builtin/reload_plugins.py +42 -0
- langchain_agentx/command/context.py +30 -0
- langchain_agentx/command/dispatcher.py +183 -0
- langchain_agentx/command/registry.py +110 -0
- langchain_agentx/command/result.py +25 -0
- langchain_agentx/command/types.py +41 -0
- langchain_agentx/config/__init__.py +14 -0
- langchain_agentx/loop/__init__.py +47 -0
- langchain_agentx/loop/config/__init__.py +20 -0
- langchain_agentx/loop/config/agent_config.py +66 -0
- langchain_agentx/loop/config/agent_loop_config.py +72 -0
- langchain_agentx/loop/config/model_context_resolver.py +105 -0
- langchain_agentx/loop/config/runtime_settings.py +50 -0
- langchain_agentx/loop/config/token_estimator.py +133 -0
- langchain_agentx/loop/context/__init__.py +66 -0
- langchain_agentx/loop/context/blocking_guard.py +97 -0
- langchain_agentx/loop/context/compaction_service.py +60 -0
- langchain_agentx/loop/context/message_utils.py +56 -0
- langchain_agentx/loop/context/pipeline.py +127 -0
- langchain_agentx/loop/context/settings.py +103 -0
- langchain_agentx/loop/context/stages/__init__.py +29 -0
- langchain_agentx/loop/context/stages/autocompact.py +140 -0
- langchain_agentx/loop/context/stages/base.py +32 -0
- langchain_agentx/loop/context/stages/collapse.py +76 -0
- langchain_agentx/loop/context/stages/microcompact.py +76 -0
- langchain_agentx/loop/context/stages/noop.py +33 -0
- langchain_agentx/loop/context/stages/snip.py +71 -0
- langchain_agentx/loop/context/stages/tool_result_budget.py +69 -0
- langchain_agentx/loop/context/types.py +79 -0
- langchain_agentx/loop/exit/__init__.py +1 -0
- langchain_agentx/loop/exit/exit_logic.py +320 -0
- langchain_agentx/loop/exit/reason_codes.py +39 -0
- langchain_agentx/loop/graph/__init__.py +5 -0
- langchain_agentx/loop/graph/builtin_loop_control.py +197 -0
- langchain_agentx/loop/graph/factory.py +1409 -0
- langchain_agentx/loop/graph/graph_edges.py +820 -0
- langchain_agentx/loop/hook/__init__.py +48 -0
- langchain_agentx/loop/hook/async_hook_runner.py +62 -0
- langchain_agentx/loop/hook/config.py +280 -0
- langchain_agentx/loop/hook/engine.py +321 -0
- langchain_agentx/loop/hook/executors/__init__.py +9 -0
- langchain_agentx/loop/hook/executors/agent.py +107 -0
- langchain_agentx/loop/hook/executors/command.py +230 -0
- langchain_agentx/loop/hook/executors/http.py +114 -0
- langchain_agentx/loop/hook/executors/prompt.py +92 -0
- langchain_agentx/loop/hook/graph_wiring.py +134 -0
- langchain_agentx/loop/hook/registry.py +262 -0
- langchain_agentx/loop/hook/trust.py +43 -0
- langchain_agentx/loop/hook/types.py +110 -0
- langchain_agentx/loop/injection/__init__.py +13 -0
- langchain_agentx/loop/injection/dedup.py +74 -0
- langchain_agentx/loop/loop_abort.py +36 -0
- langchain_agentx/loop/model/__init__.py +1 -0
- langchain_agentx/loop/model/model_node.py +648 -0
- langchain_agentx/loop/model/model_nodes.py +661 -0
- langchain_agentx/loop/model/orphan_tool_results.py +38 -0
- langchain_agentx/loop/model/retrier.py +307 -0
- langchain_agentx/loop/model/retry_bridge.py +58 -0
- langchain_agentx/loop/model/retry_events.py +35 -0
- langchain_agentx/loop/model/retry_policy.py +56 -0
- langchain_agentx/loop/model/schema_and_format.py +153 -0
- langchain_agentx/loop/model/tool_and_model_binding.py +227 -0
- langchain_agentx/loop/model/tool_call_degradation_corrector.py +443 -0
- langchain_agentx/loop/model/tool_transcript_guard.py +225 -0
- langchain_agentx/loop/prompt/__init__.py +95 -0
- langchain_agentx/loop/prompt/builder.py +61 -0
- langchain_agentx/loop/prompt/builtin.py +218 -0
- langchain_agentx/loop/prompt/compact.py +408 -0
- langchain_agentx/loop/prompt/sections.py +120 -0
- langchain_agentx/loop/runtime/__init__.py +19 -0
- langchain_agentx/loop/runtime/context.py +34 -0
- langchain_agentx/loop/runtime/context_factory.py +107 -0
- langchain_agentx/loop/runtime/subagent_execution_paths.py +68 -0
- langchain_agentx/loop/subagent/__init__.py +53 -0
- langchain_agentx/loop/subagent/async_runner.py +215 -0
- langchain_agentx/loop/subagent/context.py +209 -0
- langchain_agentx/loop/subagent/fork_worktree_notice.py +25 -0
- langchain_agentx/loop/subagent/graph.py +72 -0
- langchain_agentx/loop/subagent/orchestrator.py +391 -0
- langchain_agentx/loop/subagent/progress.py +30 -0
- langchain_agentx/loop/subagent/prompt.py +52 -0
- langchain_agentx/loop/subagent/runner.py +504 -0
- langchain_agentx/loop/subagent/transcript.py +172 -0
- langchain_agentx/memory/__init__.py +2 -0
- langchain_agentx/memory/instruction/__init__.py +12 -0
- langchain_agentx/memory/instruction/loader.py +325 -0
- langchain_agentx/memory/instruction/resolver.py +24 -0
- langchain_agentx/memory/instruction/runtime.py +83 -0
- langchain_agentx/memory/instruction/sections.py +83 -0
- langchain_agentx/memory/instruction/types.py +59 -0
- langchain_agentx/memory/memdir/__init__.py +77 -0
- langchain_agentx/memory/memdir/age.py +36 -0
- langchain_agentx/memory/memdir/agent_memory.py +380 -0
- langchain_agentx/memory/memdir/extractor.py +309 -0
- langchain_agentx/memory/memdir/loader.py +187 -0
- langchain_agentx/memory/memdir/paths.py +63 -0
- langchain_agentx/memory/memdir/recall.py +45 -0
- langchain_agentx/memory/memdir/runtime.py +43 -0
- langchain_agentx/memory/memdir/scan.py +135 -0
- langchain_agentx/memory/memdir/types.py +104 -0
- langchain_agentx/memory/session/__init__.py +76 -0
- langchain_agentx/memory/session/compact_bridge.py +208 -0
- langchain_agentx/memory/session/prompts.py +172 -0
- langchain_agentx/memory/session/session_memory.py +282 -0
- langchain_agentx/observability/__init__.py +67 -0
- langchain_agentx/observability/evaluation/__init__.py +17 -0
- langchain_agentx/observability/evaluation/checkers/__init__.py +18 -0
- langchain_agentx/observability/evaluation/checkers/base.py +34 -0
- langchain_agentx/observability/evaluation/checkers/compaction.py +38 -0
- langchain_agentx/observability/evaluation/checkers/degradation.py +50 -0
- langchain_agentx/observability/evaluation/checkers/exit_quality.py +42 -0
- langchain_agentx/observability/evaluation/checkers/session_memory.py +45 -0
- langchain_agentx/observability/evaluation/checkers/tool_behavior.py +53 -0
- langchain_agentx/observability/evaluation/retention_scheduler.py +67 -0
- langchain_agentx/observability/evaluation/service.py +102 -0
- langchain_agentx/observability/evaluation/state.py +32 -0
- langchain_agentx/observability/evaluation/store.py +258 -0
- langchain_agentx/observability/events/__init__.py +15 -0
- langchain_agentx/observability/events/langchain_agentx_event_adapter.py +832 -0
- langchain_agentx/observability/logging/__init__.py +15 -0
- langchain_agentx/observability/logging/debug_burst.py +95 -0
- langchain_agentx/observability/logging/logging_config.py +178 -0
- langchain_agentx/observability/logging/logging_contract.py +65 -0
- langchain_agentx/observability/replay/__init__.py +35 -0
- langchain_agentx/observability/replay/cli.py +91 -0
- langchain_agentx/observability/replay/service.py +83 -0
- langchain_agentx/observability/replay/store.py +278 -0
- langchain_agentx/observability/replay/ui.py +47 -0
- langchain_agentx/observability/trace/__init__.py +25 -0
- langchain_agentx/observability/trace/collector.py +560 -0
- langchain_agentx/observability/trace/event_emitter.py +183 -0
- langchain_agentx/observability/trace/hook_event_emitter.py +49 -0
- langchain_agentx/observability/trace/models.py +144 -0
- langchain_agentx/observability/trace/sqlite_store.py +873 -0
- langchain_agentx/observability/trace/trace_callback.py +295 -0
- langchain_agentx/observability/trace/trace_lifecycle_collector.py +114 -0
- langchain_agentx/plugin/__init__.py +26 -0
- langchain_agentx/plugin/builtin.py +53 -0
- langchain_agentx/plugin/config.py +113 -0
- langchain_agentx/plugin/loader.py +386 -0
- langchain_agentx/plugin/manifest.py +154 -0
- langchain_agentx/plugin/registries.py +211 -0
- langchain_agentx/plugin/types.py +142 -0
- langchain_agentx/provider/__init__.py +27 -0
- langchain_agentx/provider/anthropic.py +121 -0
- langchain_agentx/provider/compatible_chat_openai.py +86 -0
- langchain_agentx/provider/env.py +45 -0
- langchain_agentx/provider/model_profile.py +156 -0
- langchain_agentx/provider/openai.py +89 -0
- langchain_agentx/session/__init__.py +17 -0
- langchain_agentx/session/agent_session.py +320 -0
- langchain_agentx/session/conversation_factory.py +87 -0
- langchain_agentx/session/conversation_recovery.py +156 -0
- langchain_agentx/session/conversation_session.py +198 -0
- langchain_agentx/session/factory.py +143 -0
- langchain_agentx/session/protocol.py +25 -0
- langchain_agentx/task_runtime/__init__.py +113 -0
- langchain_agentx/task_runtime/core/__init__.py +51 -0
- langchain_agentx/task_runtime/core/ids.py +33 -0
- langchain_agentx/task_runtime/core/interfaces.py +115 -0
- langchain_agentx/task_runtime/core/notification_priority.py +19 -0
- langchain_agentx/task_runtime/core/types.py +136 -0
- langchain_agentx/task_runtime/integrations/__init__.py +33 -0
- langchain_agentx/task_runtime/integrations/loop_adapter.py +91 -0
- langchain_agentx/task_runtime/integrations/loop_integration.py +61 -0
- langchain_agentx/task_runtime/integrations/prefetch_providers.py +108 -0
- langchain_agentx/task_runtime/integrations/provider_factory.py +103 -0
- langchain_agentx/task_runtime/integrations/queued_command_provider.py +184 -0
- langchain_agentx/task_runtime/integrations/sqlite_queued_command_provider.py +338 -0
- langchain_agentx/task_runtime/integrations/tool_use_summary_provider.py +254 -0
- langchain_agentx/task_runtime/orchestrator/__init__.py +5 -0
- langchain_agentx/task_runtime/orchestrator/runtime.py +386 -0
- langchain_agentx/task_runtime/output/__init__.py +5 -0
- langchain_agentx/task_runtime/output/sink.py +64 -0
- langchain_agentx/task_runtime/policy/__init__.py +11 -0
- langchain_agentx/task_runtime/policy/withhold_visibility.py +32 -0
- langchain_agentx/task_runtime/queue/__init__.py +5 -0
- langchain_agentx/task_runtime/queue/in_memory.py +55 -0
- langchain_agentx/task_runtime/skill_prefetch/__init__.py +4 -0
- langchain_agentx/task_runtime/skill_prefetch/attachments.py +46 -0
- langchain_agentx/task_runtime/skill_prefetch/models.py +37 -0
- langchain_agentx/task_runtime/skill_prefetch/provider.py +344 -0
- langchain_agentx/task_runtime/store/__init__.py +6 -0
- langchain_agentx/task_runtime/store/in_memory.py +81 -0
- langchain_agentx/task_runtime/store/sqlite_store.py +281 -0
- langchain_agentx/task_runtime/tasks/__init__.py +76 -0
- langchain_agentx/task_runtime/tasks/ai_analysis/__init__.py +15 -0
- langchain_agentx/task_runtime/tasks/ai_analysis/base.py +41 -0
- langchain_agentx/task_runtime/tasks/ai_analysis/evaluation.py +67 -0
- langchain_agentx/task_runtime/tasks/ai_analysis/registry.py +36 -0
- langchain_agentx/task_runtime/tasks/ai_analysis/scheduler.py +70 -0
- langchain_agentx/task_runtime/tasks/base/__init__.py +6 -0
- langchain_agentx/task_runtime/tasks/base/contracts.py +24 -0
- langchain_agentx/task_runtime/tasks/custom/__init__.py +7 -0
- langchain_agentx/task_runtime/tasks/custom/executor.py +60 -0
- langchain_agentx/task_runtime/tasks/custom/notification.py +7 -0
- langchain_agentx/task_runtime/tasks/custom/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/custom/spec.py +33 -0
- langchain_agentx/task_runtime/tasks/dream_task/__init__.py +15 -0
- langchain_agentx/task_runtime/tasks/dream_task/executor.py +61 -0
- langchain_agentx/task_runtime/tasks/dream_task/notification.py +19 -0
- langchain_agentx/task_runtime/tasks/dream_task/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/dream_task/spec.py +35 -0
- langchain_agentx/task_runtime/tasks/dream_task/state.py +17 -0
- langchain_agentx/task_runtime/tasks/in_process_teammate/__init__.py +12 -0
- langchain_agentx/task_runtime/tasks/in_process_teammate/executor.py +36 -0
- langchain_agentx/task_runtime/tasks/in_process_teammate/notification.py +25 -0
- langchain_agentx/task_runtime/tasks/in_process_teammate/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/in_process_teammate/spec.py +63 -0
- langchain_agentx/task_runtime/tasks/local_agent/__init__.py +14 -0
- langchain_agentx/task_runtime/tasks/local_agent/executor.py +33 -0
- langchain_agentx/task_runtime/tasks/local_agent/notification.py +21 -0
- langchain_agentx/task_runtime/tasks/local_agent/runner.py +43 -0
- langchain_agentx/task_runtime/tasks/local_agent/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/local_agent/spec.py +31 -0
- langchain_agentx/task_runtime/tasks/local_bash/__init__.py +13 -0
- langchain_agentx/task_runtime/tasks/local_bash/executor.py +95 -0
- langchain_agentx/task_runtime/tasks/local_bash/notification.py +22 -0
- langchain_agentx/task_runtime/tasks/local_bash/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/local_bash/spec.py +55 -0
- langchain_agentx/task_runtime/tasks/remote_agent/__init__.py +19 -0
- langchain_agentx/task_runtime/tasks/remote_agent/backend.py +76 -0
- langchain_agentx/task_runtime/tasks/remote_agent/executor.py +37 -0
- langchain_agentx/task_runtime/tasks/remote_agent/notification.py +22 -0
- langchain_agentx/task_runtime/tasks/remote_agent/semantics.py +13 -0
- langchain_agentx/task_runtime/tasks/remote_agent/spec.py +34 -0
- langchain_agentx/task_runtime/tasks/trace_cleanup/__init__.py +19 -0
- langchain_agentx/task_runtime/tasks/trace_cleanup/bootstrap.py +95 -0
- langchain_agentx/task_runtime/tasks/trace_cleanup/executor.py +66 -0
- langchain_agentx/task_runtime/tasks/trace_cleanup/scheduler.py +169 -0
- langchain_agentx/tool_runtime/__init__.py +90 -0
- langchain_agentx/tool_runtime/adapter.py +365 -0
- langchain_agentx/tool_runtime/base.py +319 -0
- langchain_agentx/tool_runtime/errors.py +190 -0
- langchain_agentx/tool_runtime/identical_call_cache.py +110 -0
- langchain_agentx/tool_runtime/loader.py +195 -0
- langchain_agentx/tool_runtime/models.py +260 -0
- langchain_agentx/tool_runtime/permission_context.py +78 -0
- langchain_agentx/tool_runtime/pipeline.py +621 -0
- langchain_agentx/tool_runtime/policy.py +447 -0
- langchain_agentx/tool_runtime/registry.py +81 -0
- langchain_agentx/tool_runtime/resolvers/__init__.py +27 -0
- langchain_agentx/tool_runtime/resolvers/agent_session.py +125 -0
- langchain_agentx/tool_runtime/resolvers/background.py +32 -0
- langchain_agentx/tool_runtime/resolvers/base.py +20 -0
- langchain_agentx/tool_runtime/resolvers/conversation.py +22 -0
- langchain_agentx/tool_runtime/resolvers/workflow.py +73 -0
- langchain_agentx/tool_runtime/session_store.py +132 -0
- langchain_agentx/tool_runtime/smoke_test_runtime.py +294 -0
- langchain_agentx/tool_runtime/state_bridge.py +164 -0
- langchain_agentx/tools/__init__.py +26 -0
- langchain_agentx/tools/agent/__init__.py +9 -0
- langchain_agentx/tools/agent/backend.py +53 -0
- langchain_agentx/tools/agent/built_in/__init__.py +19 -0
- langchain_agentx/tools/agent/built_in/agentx_guide.py +65 -0
- langchain_agentx/tools/agent/built_in/explore.py +80 -0
- langchain_agentx/tools/agent/built_in/general.py +57 -0
- langchain_agentx/tools/agent/built_in/plan.py +89 -0
- langchain_agentx/tools/agent/built_in/statusline_setup.py +64 -0
- langchain_agentx/tools/agent/built_in/verification.py +120 -0
- langchain_agentx/tools/agent/builtin_subagent_loader.py +89 -0
- langchain_agentx/tools/agent/cwd_resolution.py +119 -0
- langchain_agentx/tools/agent/limits.py +26 -0
- langchain_agentx/tools/agent/loader.py +270 -0
- langchain_agentx/tools/agent/models.py +85 -0
- langchain_agentx/tools/agent/prompt.py +120 -0
- langchain_agentx/tools/agent/registry/__init__.py +18 -0
- langchain_agentx/tools/agent/registry/config.py +29 -0
- langchain_agentx/tools/agent/registry/registry.py +47 -0
- langchain_agentx/tools/agent/scope.py +137 -0
- langchain_agentx/tools/agent/tool.py +256 -0
- langchain_agentx/tools/bash/__init__.py +9 -0
- langchain_agentx/tools/bash/ast_security.py +571 -0
- langchain_agentx/tools/bash/backend.py +1447 -0
- langchain_agentx/tools/bash/bash_hardening.py +734 -0
- langchain_agentx/tools/bash/bash_runtime_contract.py +41 -0
- langchain_agentx/tools/bash/cwd_reporter.py +95 -0
- langchain_agentx/tools/bash/limits.py +71 -0
- langchain_agentx/tools/bash/mode_validation.py +282 -0
- langchain_agentx/tools/bash/models.py +131 -0
- langchain_agentx/tools/bash/observability.py +148 -0
- langchain_agentx/tools/bash/output_utils.py +200 -0
- langchain_agentx/tools/bash/path_security.py +2429 -0
- langchain_agentx/tools/bash/prompt.py +68 -0
- langchain_agentx/tools/bash/read_only_validation.py +589 -0
- langchain_agentx/tools/bash/result_presenter.py +324 -0
- langchain_agentx/tools/bash/sandbox_decision.py +133 -0
- langchain_agentx/tools/bash/security.py +311 -0
- langchain_agentx/tools/bash/sed_edit_parser.py +243 -0
- langchain_agentx/tools/bash/sed_validation.py +163 -0
- langchain_agentx/tools/bash/semantics.py +111 -0
- langchain_agentx/tools/bash/session_manager.py +205 -0
- langchain_agentx/tools/bash/session_runtime.py +290 -0
- langchain_agentx/tools/bash/shell_locator.py +191 -0
- langchain_agentx/tools/bash/task_runtime.py +91 -0
- langchain_agentx/tools/bash/tool.py +939 -0
- langchain_agentx/tools/bash/windows_shell_quoting.py +45 -0
- langchain_agentx/tools/glob/__init__.py +9 -0
- langchain_agentx/tools/glob/models.py +57 -0
- langchain_agentx/tools/glob/pagination.py +30 -0
- langchain_agentx/tools/glob/prompt.py +24 -0
- langchain_agentx/tools/glob/rg_list_backend.py +139 -0
- langchain_agentx/tools/glob/rg_pattern.py +44 -0
- langchain_agentx/tools/glob/tool.py +327 -0
- langchain_agentx/tools/grep/__init__.py +7 -0
- langchain_agentx/tools/grep/backend.py +375 -0
- langchain_agentx/tools/grep/models.py +127 -0
- langchain_agentx/tools/grep/prompt.py +30 -0
- langchain_agentx/tools/grep/rg_subprocess_controller.py +114 -0
- langchain_agentx/tools/grep/tool.py +475 -0
- langchain_agentx/tools/read/__init__.py +9 -0
- langchain_agentx/tools/read/backend.py +415 -0
- langchain_agentx/tools/read/limits.py +67 -0
- langchain_agentx/tools/read/models.py +156 -0
- langchain_agentx/tools/read/prompt.py +73 -0
- langchain_agentx/tools/read/tool.py +494 -0
- langchain_agentx/tools/ripgrep_plugin_exclusions.py +137 -0
- langchain_agentx/tools/skill/__init__.py +4 -0
- langchain_agentx/tools/skill/argument_substitution.py +80 -0
- langchain_agentx/tools/skill/loader.py +196 -0
- langchain_agentx/tools/skill/models.py +88 -0
- langchain_agentx/tools/skill/policy.py +80 -0
- langchain_agentx/tools/skill/prompt.py +35 -0
- langchain_agentx/tools/skill/tool.py +222 -0
- langchain_agentx/utils/__init__.py +0 -0
- langchain_agentx/utils/cwd.py +124 -0
- langchain_agentx/utils/host_platform.py +112 -0
- langchain_agentx/utils/path_hierarchy.py +48 -0
- langchain_agentx/utils/path_user_input.py +66 -0
- langchain_agentx/utils/rg_executable.py +18 -0
- langchain_agentx/utils/subprocess_text.py +101 -0
- langchain_agentx/utils/temp_paths.py +77 -0
- langchain_agentx/utils/unc_path.py +25 -0
- langchain_agentx/utils/win_reserved_paths.py +51 -0
- langchain_agentx/workflow/__init__.py +7 -0
- langchain_agentx/workflow/base.py +97 -0
- langchain_agentx/workflow/batch.py +55 -0
- langchain_agentx/workflow/dag.py +54 -0
- langchain_agentx/workspace/__init__.py +13 -0
- langchain_agentx/workspace/config.py +140 -0
- langchain_agentx/workspace/path_key_normalizer.py +30 -0
- langchain_agentx/workspace/resolver.py +74 -0
- langchain_agentx/workspace/validators.py +41 -0
- langchain_agentx_python-0.1.dist-info/LICENSE +201 -0
- langchain_agentx_python-0.1.dist-info/METADATA +513 -0
- langchain_agentx_python-0.1.dist-info/RECORD +354 -0
- langchain_agentx_python-0.1.dist-info/WHEEL +5 -0
- 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()
|