camel-ai 0.2.65__py3-none-any.whl → 0.2.83a6__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.
Potentially problematic release.
This version of camel-ai might be problematic. Click here for more details.
- camel/__init__.py +3 -3
- camel/agents/__init__.py +2 -2
- camel/agents/_types.py +9 -4
- camel/agents/_utils.py +40 -2
- camel/agents/base.py +2 -2
- camel/agents/chat_agent.py +5107 -995
- camel/agents/critic_agent.py +2 -2
- camel/agents/deductive_reasoner_agent.py +56 -56
- camel/agents/embodied_agent.py +2 -2
- camel/agents/knowledge_graph_agent.py +20 -20
- camel/agents/mcp_agent.py +35 -36
- camel/agents/multi_hop_generator_agent.py +3 -3
- camel/agents/programmed_agent_instruction.py +2 -2
- camel/agents/repo_agent.py +4 -3
- camel/agents/role_assignment_agent.py +2 -2
- camel/agents/search_agent.py +2 -2
- camel/agents/task_agent.py +2 -2
- camel/agents/tool_agents/__init__.py +2 -2
- camel/agents/tool_agents/base.py +2 -2
- camel/agents/tool_agents/hugging_face_tool_agent.py +3 -3
- camel/benchmarks/__init__.py +2 -2
- camel/benchmarks/apibank.py +5 -5
- camel/benchmarks/apibench.py +2 -2
- camel/benchmarks/base.py +2 -2
- camel/benchmarks/browsecomp.py +44 -33
- camel/benchmarks/gaia.py +17 -13
- camel/benchmarks/mock_website/README.md +1 -3
- camel/benchmarks/mock_website/mock_web.py +2 -2
- camel/benchmarks/mock_website/requirements.txt +1 -1
- camel/benchmarks/mock_website/shopping_mall/app.py +2 -2
- camel/benchmarks/mock_website/task.json +1 -1
- camel/benchmarks/nexus.py +3 -3
- camel/benchmarks/ragbench.py +2 -2
- camel/bots/__init__.py +2 -2
- camel/bots/discord/__init__.py +2 -2
- camel/bots/discord/discord_app.py +2 -2
- camel/bots/discord/discord_installation.py +2 -2
- camel/bots/discord/discord_store.py +3 -3
- camel/bots/slack/__init__.py +2 -2
- camel/bots/slack/models.py +4 -4
- camel/bots/slack/slack_app.py +2 -2
- camel/bots/telegram_bot.py +2 -2
- camel/configs/__init__.py +29 -2
- camel/configs/aihubmix_config.py +90 -0
- camel/configs/aiml_config.py +2 -2
- camel/configs/amd_config.py +70 -0
- camel/configs/anthropic_config.py +2 -2
- camel/configs/base_config.py +2 -2
- camel/configs/bedrock_config.py +5 -3
- camel/configs/cerebras_config.py +98 -0
- camel/configs/cohere_config.py +2 -2
- camel/configs/cometapi_config.py +106 -0
- camel/configs/crynux_config.py +2 -2
- camel/configs/deepseek_config.py +9 -8
- camel/configs/function_gemma_config.py +59 -0
- camel/configs/gemini_config.py +6 -4
- camel/configs/groq_config.py +6 -4
- camel/configs/internlm_config.py +6 -4
- camel/configs/litellm_config.py +2 -2
- camel/configs/lmstudio_config.py +6 -4
- camel/configs/minimax_config.py +95 -0
- camel/configs/mistral_config.py +2 -2
- camel/configs/modelscope_config.py +5 -3
- camel/configs/moonshot_config.py +2 -2
- camel/configs/nebius_config.py +105 -0
- camel/configs/netmind_config.py +2 -2
- camel/configs/novita_config.py +2 -2
- camel/configs/nvidia_config.py +2 -2
- camel/configs/ollama_config.py +2 -2
- camel/configs/openai_config.py +5 -3
- camel/configs/openrouter_config.py +6 -4
- camel/configs/ppio_config.py +2 -2
- camel/configs/qianfan_config.py +85 -0
- camel/configs/qwen_config.py +2 -2
- camel/configs/reka_config.py +2 -2
- camel/configs/samba_config.py +6 -4
- camel/configs/sglang_config.py +2 -2
- camel/configs/siliconflow_config.py +2 -2
- camel/configs/togetherai_config.py +2 -2
- camel/configs/vllm_config.py +4 -2
- camel/configs/watsonx_config.py +2 -2
- camel/configs/yi_config.py +6 -4
- camel/configs/zhipuai_config.py +6 -4
- camel/data_collectors/__init__.py +2 -2
- camel/data_collectors/alpaca_collector.py +18 -9
- camel/data_collectors/base.py +2 -2
- camel/data_collectors/sharegpt_collector.py +2 -2
- camel/datagen/__init__.py +2 -2
- camel/datagen/cot_datagen.py +3 -3
- camel/datagen/evol_instruct/__init__.py +2 -2
- camel/datagen/evol_instruct/evol_instruct.py +2 -2
- camel/datagen/evol_instruct/scorer.py +12 -12
- camel/datagen/evol_instruct/templates.py +16 -16
- camel/datagen/self_improving_cot.py +5 -5
- camel/datagen/self_instruct/__init__.py +2 -2
- camel/datagen/self_instruct/filter/__init__.py +2 -2
- camel/datagen/self_instruct/filter/filter_function.py +2 -2
- camel/datagen/self_instruct/filter/filter_registry.py +2 -2
- camel/datagen/self_instruct/filter/instruction_filter.py +2 -2
- camel/datagen/self_instruct/self_instruct.py +2 -2
- camel/datagen/self_instruct/templates.py +47 -47
- camel/datagen/source2synth/__init__.py +2 -2
- camel/datagen/source2synth/data_processor.py +2 -2
- camel/datagen/source2synth/models.py +2 -2
- camel/datagen/source2synth/user_data_processor_config.py +2 -2
- camel/datahubs/__init__.py +2 -2
- camel/datahubs/base.py +2 -2
- camel/datahubs/huggingface.py +2 -2
- camel/datahubs/models.py +2 -2
- camel/datasets/__init__.py +2 -2
- camel/datasets/base_generator.py +41 -12
- camel/datasets/few_shot_generator.py +18 -18
- camel/datasets/models.py +2 -2
- camel/datasets/self_instruct_generator.py +2 -2
- camel/datasets/static_dataset.py +2 -2
- camel/embeddings/__init__.py +2 -2
- camel/embeddings/azure_embedding.py +2 -2
- camel/embeddings/base.py +2 -2
- camel/embeddings/gemini_embedding.py +2 -2
- camel/embeddings/jina_embedding.py +2 -2
- camel/embeddings/mistral_embedding.py +2 -2
- camel/embeddings/openai_compatible_embedding.py +2 -2
- camel/embeddings/openai_embedding.py +2 -2
- camel/embeddings/sentence_transformers_embeddings.py +2 -2
- camel/embeddings/together_embedding.py +2 -2
- camel/embeddings/vlm_embedding.py +2 -2
- camel/environments/__init__.py +14 -2
- camel/environments/models.py +2 -2
- camel/environments/multi_step.py +2 -2
- camel/environments/rlcards_env.py +860 -0
- camel/environments/single_step.py +30 -5
- camel/environments/tic_tac_toe.py +3 -3
- camel/extractors/__init__.py +2 -2
- camel/extractors/base.py +2 -2
- camel/extractors/python_strategies.py +2 -2
- camel/generators.py +2 -2
- camel/human.py +2 -2
- camel/interpreters/__init__.py +4 -2
- camel/interpreters/base.py +2 -2
- camel/interpreters/docker/Dockerfile +14 -24
- camel/interpreters/docker_interpreter.py +5 -4
- camel/interpreters/e2b_interpreter.py +36 -3
- camel/interpreters/internal_python_interpreter.py +53 -4
- camel/interpreters/interpreter_error.py +2 -2
- camel/interpreters/ipython_interpreter.py +2 -2
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/interpreters/subprocess_interpreter.py +2 -2
- camel/loaders/__init__.py +13 -4
- camel/loaders/apify_reader.py +2 -2
- camel/loaders/base_io.py +2 -2
- camel/loaders/base_loader.py +85 -0
- camel/loaders/chunkr_reader.py +11 -2
- camel/loaders/crawl4ai_reader.py +2 -2
- camel/loaders/firecrawl_reader.py +6 -6
- camel/loaders/jina_url_reader.py +2 -2
- camel/loaders/markitdown.py +2 -2
- camel/loaders/mineru_extractor.py +2 -2
- camel/loaders/mistral_reader.py +2 -2
- camel/loaders/scrapegraph_reader.py +2 -2
- camel/loaders/unstructured_io.py +2 -2
- camel/logger.py +5 -5
- camel/memories/__init__.py +2 -2
- camel/memories/agent_memories.py +86 -3
- camel/memories/base.py +36 -2
- camel/memories/blocks/__init__.py +2 -2
- camel/memories/blocks/chat_history_block.py +125 -7
- camel/memories/blocks/vectordb_block.py +10 -3
- camel/memories/context_creators/__init__.py +2 -2
- camel/memories/context_creators/score_based.py +109 -230
- camel/memories/records.py +90 -10
- camel/messages/__init__.py +2 -2
- camel/messages/base.py +178 -43
- camel/messages/conversion/__init__.py +2 -2
- camel/messages/conversion/alpaca.py +2 -2
- camel/messages/conversion/conversation_models.py +2 -2
- camel/messages/conversion/sharegpt/__init__.py +2 -2
- camel/messages/conversion/sharegpt/function_call_formatter.py +2 -2
- camel/messages/conversion/sharegpt/hermes/__init__.py +2 -2
- camel/messages/conversion/sharegpt/hermes/hermes_function_formatter.py +2 -2
- camel/messages/func_message.py +54 -17
- camel/models/__init__.py +18 -2
- camel/models/_utils.py +3 -3
- camel/models/aihubmix_model.py +83 -0
- camel/models/aiml_model.py +11 -18
- camel/models/amd_model.py +101 -0
- camel/models/anthropic_model.py +127 -20
- camel/models/aws_bedrock_model.py +12 -35
- camel/models/azure_openai_model.py +214 -115
- camel/models/base_audio_model.py +5 -3
- camel/models/base_model.py +378 -31
- camel/models/cerebras_model.py +83 -0
- camel/models/cohere_model.py +18 -49
- camel/models/cometapi_model.py +83 -0
- camel/models/crynux_model.py +11 -18
- camel/models/deepseek_model.py +20 -84
- camel/models/fish_audio_model.py +8 -2
- camel/models/function_gemma_model.py +889 -0
- camel/models/gemini_model.py +391 -52
- camel/models/groq_model.py +11 -19
- camel/models/internlm_model.py +11 -18
- camel/models/litellm_model.py +57 -49
- camel/models/lmstudio_model.py +17 -20
- camel/models/minimax_model.py +83 -0
- camel/models/mistral_model.py +20 -47
- camel/models/model_factory.py +39 -3
- camel/models/model_manager.py +26 -8
- camel/models/modelscope_model.py +13 -193
- camel/models/moonshot_model.py +183 -21
- camel/models/nebius_model.py +83 -0
- camel/models/nemotron_model.py +19 -9
- camel/models/netmind_model.py +11 -18
- camel/models/novita_model.py +11 -18
- camel/models/nvidia_model.py +11 -18
- camel/models/ollama_model.py +14 -21
- camel/models/openai_audio_models.py +2 -2
- camel/models/openai_compatible_model.py +190 -71
- camel/models/openai_model.py +192 -86
- camel/models/openrouter_model.py +11 -19
- camel/models/ppio_model.py +11 -18
- camel/models/qianfan_model.py +89 -0
- camel/models/qwen_model.py +13 -193
- camel/models/reka_model.py +23 -49
- camel/models/reward/__init__.py +2 -2
- camel/models/reward/base_reward_model.py +2 -2
- camel/models/reward/evaluator.py +2 -2
- camel/models/reward/nemotron_model.py +2 -2
- camel/models/reward/skywork_model.py +2 -2
- camel/models/samba_model.py +50 -75
- camel/models/sglang_model.py +90 -68
- camel/models/siliconflow_model.py +12 -35
- camel/models/stub_model.py +10 -7
- camel/models/togetherai_model.py +11 -18
- camel/models/vllm_model.py +10 -18
- camel/models/volcano_model.py +158 -19
- camel/models/watsonx_model.py +9 -47
- camel/models/yi_model.py +11 -18
- camel/models/zhipuai_model.py +70 -18
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/personas/__init__.py +2 -2
- camel/personas/persona.py +2 -2
- camel/personas/persona_hub.py +2 -2
- camel/prompts/__init__.py +2 -2
- camel/prompts/ai_society.py +2 -2
- camel/prompts/base.py +2 -2
- camel/prompts/code.py +2 -2
- camel/prompts/evaluation.py +2 -2
- camel/prompts/generate_text_embedding_data.py +2 -2
- camel/prompts/image_craft.py +2 -2
- camel/prompts/misalignment.py +2 -2
- camel/prompts/multi_condition_image_craft.py +2 -2
- camel/prompts/object_recognition.py +2 -2
- camel/prompts/persona_hub.py +3 -3
- camel/prompts/prompt_templates.py +2 -2
- camel/prompts/role_description_prompt_template.py +2 -2
- camel/prompts/solution_extraction.py +8 -8
- camel/prompts/task_prompt_template.py +2 -2
- camel/prompts/translation.py +2 -2
- camel/prompts/video_description_prompt.py +3 -3
- camel/responses/__init__.py +2 -2
- camel/responses/agent_responses.py +2 -2
- camel/retrievers/__init__.py +2 -2
- camel/retrievers/auto_retriever.py +3 -2
- camel/retrievers/base.py +2 -2
- camel/retrievers/bm25_retriever.py +2 -2
- camel/retrievers/cohere_rerank_retriever.py +2 -2
- camel/retrievers/hybrid_retrival.py +2 -2
- camel/retrievers/vector_retriever.py +2 -2
- camel/runtimes/Dockerfile.multi-toolkit +90 -0
- camel/runtimes/__init__.py +2 -2
- camel/runtimes/api.py +79 -23
- camel/runtimes/base.py +2 -2
- camel/runtimes/configs.py +13 -13
- camel/runtimes/daytona_runtime.py +17 -18
- camel/runtimes/docker_runtime.py +12 -12
- camel/runtimes/llm_guard_runtime.py +26 -26
- camel/runtimes/remote_http_runtime.py +11 -11
- camel/runtimes/ubuntu_docker_runtime.py +2 -2
- camel/runtimes/utils/__init__.py +2 -2
- camel/runtimes/utils/function_risk_toolkit.py +2 -2
- camel/runtimes/utils/ignore_risk_toolkit.py +2 -2
- camel/schemas/__init__.py +2 -2
- camel/schemas/base.py +2 -2
- camel/schemas/openai_converter.py +3 -3
- camel/schemas/outlines_converter.py +2 -2
- camel/services/agent_openapi_server.py +380 -0
- camel/societies/__init__.py +4 -2
- camel/societies/babyagi_playing.py +2 -2
- camel/societies/role_playing.py +201 -80
- camel/societies/workforce/__init__.py +10 -3
- camel/societies/workforce/base.py +2 -2
- camel/societies/workforce/events.py +145 -0
- camel/societies/workforce/prompts.py +259 -33
- camel/societies/workforce/role_playing_worker.py +88 -31
- camel/societies/workforce/single_agent_worker.py +638 -40
- camel/societies/workforce/structured_output_handler.py +512 -0
- camel/societies/workforce/task_channel.py +182 -38
- camel/societies/workforce/utils.py +780 -65
- camel/societies/workforce/worker.py +92 -26
- camel/societies/workforce/workflow_memory_manager.py +1746 -0
- camel/societies/workforce/workforce.py +5354 -372
- camel/societies/workforce/workforce_callback.py +103 -0
- camel/societies/workforce/workforce_logger.py +647 -0
- camel/societies/workforce/workforce_metrics.py +33 -0
- camel/storages/__init__.py +6 -2
- camel/storages/graph_storages/__init__.py +2 -2
- camel/storages/graph_storages/base.py +2 -2
- camel/storages/graph_storages/graph_element.py +2 -2
- camel/storages/graph_storages/nebula_graph.py +4 -4
- camel/storages/graph_storages/neo4j_graph.py +7 -7
- camel/storages/key_value_storages/__init__.py +2 -2
- camel/storages/key_value_storages/base.py +2 -2
- camel/storages/key_value_storages/in_memory.py +2 -2
- camel/storages/key_value_storages/json.py +17 -4
- camel/storages/key_value_storages/mem0_cloud.py +50 -49
- camel/storages/key_value_storages/redis.py +2 -2
- camel/storages/object_storages/__init__.py +2 -2
- camel/storages/object_storages/amazon_s3.py +2 -2
- camel/storages/object_storages/azure_blob.py +2 -2
- camel/storages/object_storages/base.py +2 -2
- camel/storages/object_storages/google_cloud.py +3 -3
- camel/storages/vectordb_storages/__init__.py +8 -2
- camel/storages/vectordb_storages/base.py +2 -2
- camel/storages/vectordb_storages/chroma.py +731 -0
- camel/storages/vectordb_storages/faiss.py +2 -2
- camel/storages/vectordb_storages/milvus.py +2 -2
- camel/storages/vectordb_storages/oceanbase.py +15 -15
- camel/storages/vectordb_storages/pgvector.py +349 -0
- camel/storages/vectordb_storages/qdrant.py +6 -6
- camel/storages/vectordb_storages/surreal.py +372 -0
- camel/storages/vectordb_storages/tidb.py +11 -8
- camel/storages/vectordb_storages/weaviate.py +2 -2
- camel/tasks/__init__.py +2 -2
- camel/tasks/task.py +348 -26
- camel/tasks/task_prompt.py +3 -3
- camel/terminators/__init__.py +2 -2
- camel/terminators/base.py +2 -2
- camel/terminators/response_terminator.py +2 -2
- camel/terminators/token_limit_terminator.py +2 -2
- camel/toolkits/__init__.py +57 -10
- camel/toolkits/aci_toolkit.py +66 -21
- camel/toolkits/arxiv_toolkit.py +8 -8
- camel/toolkits/ask_news_toolkit.py +2 -2
- camel/toolkits/async_browser_toolkit.py +4 -4
- camel/toolkits/audio_analysis_toolkit.py +3 -3
- camel/toolkits/base.py +106 -6
- camel/toolkits/bohrium_toolkit.py +2 -2
- camel/toolkits/browser_toolkit.py +34 -21
- camel/toolkits/browser_toolkit_commons.py +4 -4
- camel/toolkits/code_execution.py +31 -4
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/craw4ai_toolkit.py +93 -0
- camel/toolkits/dappier_toolkit.py +12 -8
- camel/toolkits/data_commons_toolkit.py +2 -2
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/earth_science_toolkit.py +5367 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +49 -0
- camel/toolkits/excel_toolkit.py +905 -71
- camel/toolkits/file_toolkit.py +1402 -0
- camel/toolkits/function_tool.py +205 -27
- camel/toolkits/github_toolkit.py +109 -22
- camel/toolkits/gmail_toolkit.py +1839 -0
- camel/toolkits/google_calendar_toolkit.py +40 -6
- camel/toolkits/google_drive_mcp_toolkit.py +54 -0
- camel/toolkits/google_maps_toolkit.py +2 -2
- camel/toolkits/google_scholar_toolkit.py +2 -2
- camel/toolkits/human_toolkit.py +36 -12
- camel/toolkits/hybrid_browser_toolkit/__init__.py +18 -0
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +185 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +246 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +1958 -0
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +4589 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +33 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-scripts.js +125 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +1940 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +233 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +589 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/index.ts +7 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +129 -0
- camel/toolkits/hybrid_browser_toolkit/ts/tsconfig.json +27 -0
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +325 -0
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +1037 -0
- camel/toolkits/hybrid_browser_toolkit_py/__init__.py +17 -0
- camel/toolkits/hybrid_browser_toolkit_py/actions.py +575 -0
- camel/toolkits/hybrid_browser_toolkit_py/agent.py +311 -0
- camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +787 -0
- camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +490 -0
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +2390 -0
- camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +233 -0
- camel/toolkits/hybrid_browser_toolkit_py/stealth_script.js +0 -0
- camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +1043 -0
- camel/toolkits/image_analysis_toolkit.py +3 -6
- camel/toolkits/image_generation_toolkit.py +390 -0
- camel/toolkits/jina_reranker_toolkit.py +5 -6
- camel/toolkits/klavis_toolkit.py +7 -3
- camel/toolkits/linkedin_toolkit.py +2 -2
- camel/toolkits/markitdown_toolkit.py +104 -0
- camel/toolkits/math_toolkit.py +66 -12
- camel/toolkits/mcp_toolkit.py +412 -36
- camel/toolkits/memory_toolkit.py +7 -3
- camel/toolkits/meshy_toolkit.py +2 -2
- camel/toolkits/message_agent_toolkit.py +608 -0
- camel/toolkits/message_integration.py +728 -0
- camel/toolkits/microsoft_outlook_mail_toolkit.py +1885 -0
- camel/toolkits/mineru_toolkit.py +2 -2
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/networkx_toolkit.py +2 -2
- camel/toolkits/note_taking_toolkit.py +277 -0
- camel/toolkits/notion_mcp_toolkit.py +224 -0
- camel/toolkits/notion_toolkit.py +2 -2
- camel/toolkits/open_api_specs/biztoc/__init__.py +2 -2
- camel/toolkits/open_api_specs/biztoc/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/coursera/__init__.py +2 -2
- camel/toolkits/open_api_specs/create_qr_code/__init__.py +2 -2
- camel/toolkits/open_api_specs/klarna/__init__.py +2 -2
- camel/toolkits/open_api_specs/nasa_apod/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/outschool/openapi.yaml +1 -1
- camel/toolkits/open_api_specs/outschool/paths/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/paths/get_classes.py +2 -2
- camel/toolkits/open_api_specs/outschool/paths/search_teachers.py +2 -2
- camel/toolkits/open_api_specs/security_config.py +2 -2
- camel/toolkits/open_api_specs/speak/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/web_scraper/paths/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/paths/scraper.py +2 -2
- camel/toolkits/open_api_toolkit.py +2 -2
- camel/toolkits/openbb_toolkit.py +7 -3
- camel/toolkits/origene_mcp_toolkit.py +56 -0
- camel/toolkits/page_script.js +53 -53
- camel/toolkits/playwright_mcp_toolkit.py +13 -31
- camel/toolkits/pptx_toolkit.py +36 -23
- camel/toolkits/pubmed_toolkit.py +2 -2
- camel/toolkits/pulse_mcp_search_toolkit.py +2 -2
- camel/toolkits/pyautogui_toolkit.py +2 -2
- camel/toolkits/reddit_toolkit.py +2 -2
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/retrieval_toolkit.py +2 -2
- camel/toolkits/screenshot_toolkit.py +213 -0
- camel/toolkits/search_toolkit.py +606 -156
- camel/toolkits/searxng_toolkit.py +2 -2
- camel/toolkits/semantic_scholar_toolkit.py +2 -2
- camel/toolkits/slack_toolkit.py +108 -58
- camel/toolkits/sql_toolkit.py +712 -0
- camel/toolkits/stripe_toolkit.py +2 -2
- camel/toolkits/sympy_toolkit.py +3 -3
- camel/toolkits/task_planning_toolkit.py +5 -5
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +1281 -0
- camel/toolkits/terminal_toolkit/utils.py +659 -0
- camel/toolkits/thinking_toolkit.py +3 -3
- camel/toolkits/twitter_toolkit.py +2 -2
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +109 -29
- camel/toolkits/video_download_toolkit.py +19 -16
- camel/toolkits/weather_toolkit.py +2 -2
- camel/toolkits/web_deploy_toolkit.py +1219 -0
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/toolkits/whatsapp_toolkit.py +2 -2
- camel/toolkits/wolfram_alpha_toolkit.py +2 -2
- camel/toolkits/zapier_toolkit.py +7 -3
- camel/types/__init__.py +4 -4
- camel/types/agents/__init__.py +2 -2
- camel/types/agents/tool_calling_record.py +6 -3
- camel/types/enums.py +381 -41
- camel/types/mcp_registries.py +2 -2
- camel/types/openai_types.py +4 -4
- camel/types/unified_model_type.py +46 -10
- camel/utils/__init__.py +5 -2
- camel/utils/agent_context.py +41 -0
- camel/utils/async_func.py +2 -2
- camel/utils/chunker/__init__.py +2 -2
- camel/utils/chunker/base.py +2 -2
- camel/utils/chunker/code_chunker.py +2 -2
- camel/utils/chunker/uio_chunker.py +2 -2
- camel/utils/commons.py +38 -7
- camel/utils/constants.py +5 -2
- camel/utils/context_utils.py +1134 -0
- camel/utils/deduplication.py +2 -2
- camel/utils/filename.py +2 -2
- camel/utils/langfuse.py +18 -10
- camel/utils/mcp.py +140 -6
- camel/utils/mcp_client.py +48 -38
- camel/utils/message_summarizer.py +148 -0
- camel/utils/response_format.py +2 -2
- camel/utils/token_counting.py +45 -22
- camel/utils/tool_result.py +44 -0
- camel/verifiers/__init__.py +2 -2
- camel/verifiers/base.py +2 -2
- camel/verifiers/math_verifier.py +2 -2
- camel/verifiers/models.py +2 -2
- camel/verifiers/physics_verifier.py +2 -2
- camel/verifiers/python_verifier.py +2 -2
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/METADATA +355 -117
- camel_ai-0.2.83a6.dist-info/RECORD +511 -0
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/WHEEL +1 -1
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/licenses/LICENSE +1 -1
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/dalle_toolkit.py +0 -175
- camel/toolkits/file_write_toolkit.py +0 -444
- camel/toolkits/openai_agent_toolkit.py +0 -135
- camel/toolkits/terminal_toolkit.py +0 -1037
- camel_ai-0.2.65.dist-info/RECORD +0 -426
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import select
|
|
17
|
+
import shlex
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from queue import Empty, Full, Queue
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from camel.logger import get_logger
|
|
26
|
+
from camel.toolkits import manual_timeout
|
|
27
|
+
from camel.toolkits.base import BaseToolkit
|
|
28
|
+
from camel.toolkits.function_tool import FunctionTool
|
|
29
|
+
from camel.toolkits.terminal_toolkit.utils import (
|
|
30
|
+
check_nodejs_availability,
|
|
31
|
+
clone_current_environment,
|
|
32
|
+
ensure_uv_available,
|
|
33
|
+
sanitize_command,
|
|
34
|
+
setup_initial_env_with_uv,
|
|
35
|
+
setup_initial_env_with_venv,
|
|
36
|
+
)
|
|
37
|
+
from camel.utils import MCPServer
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
# Try to import docker, but don't make it a hard requirement
|
|
42
|
+
try:
|
|
43
|
+
import docker
|
|
44
|
+
from docker.errors import APIError, NotFound
|
|
45
|
+
except ImportError:
|
|
46
|
+
docker = None
|
|
47
|
+
NotFound = None
|
|
48
|
+
APIError = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_plain(text: str) -> str:
|
|
52
|
+
r"""Convert ANSI text to plain text using rich if available."""
|
|
53
|
+
try:
|
|
54
|
+
from rich.text import Text as _RichText
|
|
55
|
+
|
|
56
|
+
return _RichText.from_ansi(text).plain
|
|
57
|
+
except Exception:
|
|
58
|
+
return text
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@MCPServer()
|
|
62
|
+
class TerminalToolkit(BaseToolkit):
|
|
63
|
+
r"""A toolkit for LLM agents to execute and interact with terminal commands
|
|
64
|
+
in either a local or a sandboxed Docker environment.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
timeout (Optional[float]): The default timeout in seconds for blocking
|
|
68
|
+
commands. Defaults to 20.0.
|
|
69
|
+
working_directory (Optional[str]): The base directory for operations.
|
|
70
|
+
For the local backend, this acts as a security sandbox.
|
|
71
|
+
For the Docker backend, this sets the working directory inside
|
|
72
|
+
the container.
|
|
73
|
+
If not specified, defaults to "./workspace" for local and
|
|
74
|
+
"/workspace" for Docker.
|
|
75
|
+
use_docker_backend (bool): If True, all commands are executed in a
|
|
76
|
+
Docker container. Defaults to False.
|
|
77
|
+
docker_container_name (Optional[str]): The name of the Docker
|
|
78
|
+
container to use. Required if use_docker_backend is True.
|
|
79
|
+
session_logs_dir (Optional[str]): The directory to store session
|
|
80
|
+
logs. Defaults to a 'terminal_logs' subfolder in the
|
|
81
|
+
working directory.
|
|
82
|
+
safe_mode (bool): Whether to apply security checks to commands.
|
|
83
|
+
Defaults to True.
|
|
84
|
+
allowed_commands (Optional[List[str]]): List of allowed commands
|
|
85
|
+
when safe_mode is True. If None, uses default safety rules.
|
|
86
|
+
clone_current_env (bool): Whether to clone the current Python
|
|
87
|
+
environment for local execution. Defaults to False.
|
|
88
|
+
install_dependencies (List): A list of user specified libraries
|
|
89
|
+
to install.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
timeout: Optional[float] = 20.0,
|
|
95
|
+
working_directory: Optional[str] = None,
|
|
96
|
+
use_docker_backend: bool = False,
|
|
97
|
+
docker_container_name: Optional[str] = None,
|
|
98
|
+
session_logs_dir: Optional[str] = None,
|
|
99
|
+
safe_mode: bool = True,
|
|
100
|
+
allowed_commands: Optional[List[str]] = None,
|
|
101
|
+
clone_current_env: bool = False,
|
|
102
|
+
install_dependencies: Optional[List[str]] = None,
|
|
103
|
+
):
|
|
104
|
+
# auto-detect if running inside a CAMEL runtime container
|
|
105
|
+
# when inside a runtime, use local execution (already sandboxed)
|
|
106
|
+
runtime_env = os.environ.get("CAMEL_RUNTIME", "").lower()
|
|
107
|
+
self._in_runtime = runtime_env == "true"
|
|
108
|
+
if self._in_runtime and use_docker_backend:
|
|
109
|
+
logger.info(
|
|
110
|
+
"Detected CAMEL_RUNTIME environment - disabling Docker "
|
|
111
|
+
"backend since we're already inside a sandboxed container"
|
|
112
|
+
)
|
|
113
|
+
use_docker_backend = False
|
|
114
|
+
docker_container_name = None
|
|
115
|
+
|
|
116
|
+
self.use_docker_backend = use_docker_backend
|
|
117
|
+
self.timeout = timeout
|
|
118
|
+
self.shell_sessions: Dict[str, Dict[str, Any]] = {}
|
|
119
|
+
# Thread-safe guard for concurrent access to
|
|
120
|
+
# shell_sessions and session state
|
|
121
|
+
self._session_lock = threading.RLock()
|
|
122
|
+
# Condition variable for efficient waiting on new output
|
|
123
|
+
self._output_condition = threading.Condition(self._session_lock)
|
|
124
|
+
|
|
125
|
+
# Initialize docker_workdir with proper type
|
|
126
|
+
self.docker_workdir: Optional[str] = None
|
|
127
|
+
|
|
128
|
+
if self.use_docker_backend:
|
|
129
|
+
# For Docker backend, working_directory is path inside container
|
|
130
|
+
if working_directory:
|
|
131
|
+
self.docker_workdir = working_directory
|
|
132
|
+
else:
|
|
133
|
+
self.docker_workdir = "/workspace"
|
|
134
|
+
# For logs and local file operations, use a local workspace
|
|
135
|
+
camel_workdir = os.environ.get("CAMEL_WORKDIR")
|
|
136
|
+
if camel_workdir:
|
|
137
|
+
self.working_dir = os.path.abspath(camel_workdir)
|
|
138
|
+
else:
|
|
139
|
+
self.working_dir = os.path.abspath("./workspace")
|
|
140
|
+
else:
|
|
141
|
+
# For local backend, working_directory is the local path
|
|
142
|
+
if working_directory:
|
|
143
|
+
self.working_dir = os.path.abspath(working_directory)
|
|
144
|
+
else:
|
|
145
|
+
camel_workdir = os.environ.get("CAMEL_WORKDIR")
|
|
146
|
+
if camel_workdir:
|
|
147
|
+
self.working_dir = os.path.abspath(camel_workdir)
|
|
148
|
+
else:
|
|
149
|
+
self.working_dir = os.path.abspath("./workspace")
|
|
150
|
+
|
|
151
|
+
# Only create local directory for logs and local backend operations
|
|
152
|
+
if not os.path.exists(self.working_dir):
|
|
153
|
+
os.makedirs(self.working_dir, exist_ok=True)
|
|
154
|
+
self.safe_mode = safe_mode
|
|
155
|
+
|
|
156
|
+
# Initialize whitelist of allowed commands if provided
|
|
157
|
+
self.allowed_commands = (
|
|
158
|
+
set(allowed_commands) if allowed_commands else None
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Environment management attributes
|
|
162
|
+
self.clone_current_env = clone_current_env
|
|
163
|
+
self.cloned_env_path: Optional[str] = None
|
|
164
|
+
self.initial_env_path: Optional[str] = None
|
|
165
|
+
self.python_executable = sys.executable
|
|
166
|
+
self.install_dependencies = install_dependencies or []
|
|
167
|
+
|
|
168
|
+
self.log_dir = os.path.abspath(
|
|
169
|
+
session_logs_dir or os.path.join(self.working_dir, "terminal_logs")
|
|
170
|
+
)
|
|
171
|
+
self.blocking_log_file = os.path.join(
|
|
172
|
+
self.log_dir, "blocking_commands.log"
|
|
173
|
+
)
|
|
174
|
+
self.os_type = platform.system()
|
|
175
|
+
|
|
176
|
+
os.makedirs(self.log_dir, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
# Clean the file in terminal_logs folder
|
|
179
|
+
for file in os.listdir(self.log_dir):
|
|
180
|
+
if file.endswith(".log"):
|
|
181
|
+
os.remove(os.path.join(self.log_dir, file))
|
|
182
|
+
|
|
183
|
+
if self.use_docker_backend:
|
|
184
|
+
if docker is None:
|
|
185
|
+
raise ImportError(
|
|
186
|
+
"The 'docker' library is required to use the "
|
|
187
|
+
"Docker backend. Please install it with "
|
|
188
|
+
"'pip install docker'."
|
|
189
|
+
)
|
|
190
|
+
if not docker_container_name:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"docker_container_name must be "
|
|
193
|
+
"provided when using Docker backend."
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
self.docker_api_client = docker.APIClient(
|
|
197
|
+
base_url='unix://var/run/docker.sock'
|
|
198
|
+
)
|
|
199
|
+
self.docker_client = docker.from_env()
|
|
200
|
+
try:
|
|
201
|
+
# Try to get existing container
|
|
202
|
+
self.container = self.docker_client.containers.get(
|
|
203
|
+
docker_container_name
|
|
204
|
+
)
|
|
205
|
+
logger.info(
|
|
206
|
+
f"Successfully attached to existing Docker container "
|
|
207
|
+
f"'{docker_container_name}'."
|
|
208
|
+
)
|
|
209
|
+
except NotFound:
|
|
210
|
+
raise RuntimeError(
|
|
211
|
+
f"Container '{docker_container_name}' not found."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Ensure the working directory exists inside the container
|
|
215
|
+
if self.docker_workdir:
|
|
216
|
+
try:
|
|
217
|
+
quoted_dir = shlex.quote(self.docker_workdir)
|
|
218
|
+
mkdir_cmd = f'sh -lc "mkdir -p -- {quoted_dir}"'
|
|
219
|
+
_init = self.docker_api_client.exec_create(
|
|
220
|
+
self.container.id, mkdir_cmd
|
|
221
|
+
)
|
|
222
|
+
self.docker_api_client.exec_start(_init['Id'])
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"[Docker] Failed to ensure workdir "
|
|
226
|
+
f"'{self.docker_workdir}': {e}"
|
|
227
|
+
)
|
|
228
|
+
except APIError as e:
|
|
229
|
+
raise RuntimeError(f"Failed to connect to Docker daemon: {e}")
|
|
230
|
+
|
|
231
|
+
# Set up environments (only for local backend, skip in runtime mode)
|
|
232
|
+
if self._in_runtime:
|
|
233
|
+
logger.info(
|
|
234
|
+
"[ENV] Skipping environment setup - running inside "
|
|
235
|
+
"CAMEL runtime container"
|
|
236
|
+
)
|
|
237
|
+
elif not self.use_docker_backend:
|
|
238
|
+
if self.clone_current_env:
|
|
239
|
+
self._setup_cloned_environment()
|
|
240
|
+
else:
|
|
241
|
+
# Default: set up initial environment with Python 3.10
|
|
242
|
+
self._setup_initial_environment()
|
|
243
|
+
elif self.clone_current_env:
|
|
244
|
+
logger.info(
|
|
245
|
+
"[ENV CLONE] Skipping environment setup for Docker backend "
|
|
246
|
+
"- container is already isolated"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Install dependencies
|
|
250
|
+
if self.install_dependencies:
|
|
251
|
+
self._install_dependencies()
|
|
252
|
+
|
|
253
|
+
def _setup_cloned_environment(self):
|
|
254
|
+
r"""Set up a cloned Python environment."""
|
|
255
|
+
self.cloned_env_path = os.path.join(self.working_dir, ".venv")
|
|
256
|
+
|
|
257
|
+
def update_callback(msg: str):
|
|
258
|
+
logger.info(f"[ENV CLONE] {msg.strip()}")
|
|
259
|
+
|
|
260
|
+
success = clone_current_environment(
|
|
261
|
+
self.cloned_env_path, self.working_dir, update_callback
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if success:
|
|
265
|
+
# Update python executable to use the cloned environment
|
|
266
|
+
if self.os_type == 'Windows':
|
|
267
|
+
self.python_executable = os.path.join(
|
|
268
|
+
self.cloned_env_path, "Scripts", "python.exe"
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
self.python_executable = os.path.join(
|
|
272
|
+
self.cloned_env_path, "bin", "python"
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
logger.info(
|
|
276
|
+
"[ENV CLONE] Failed to create cloned environment, "
|
|
277
|
+
"using system Python"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _install_dependencies(self):
|
|
281
|
+
r"""Install user specified dependencies in the current environment."""
|
|
282
|
+
if not self.install_dependencies:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
logger.info("Installing dependencies...")
|
|
286
|
+
|
|
287
|
+
if self.use_docker_backend:
|
|
288
|
+
pkg_str = " ".join(
|
|
289
|
+
shlex.quote(p) for p in self.install_dependencies
|
|
290
|
+
)
|
|
291
|
+
install_cmd = f'sh -lc "pip install {pkg_str}"'
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
exec_id = self.docker_api_client.exec_create(
|
|
295
|
+
self.container.id, install_cmd
|
|
296
|
+
)["Id"]
|
|
297
|
+
log = self.docker_api_client.exec_start(exec_id)
|
|
298
|
+
logger.info(f"Package installation output:\n{log}")
|
|
299
|
+
|
|
300
|
+
# Check exit code to ensure installation succeeded
|
|
301
|
+
exec_info = self.docker_api_client.exec_inspect(exec_id)
|
|
302
|
+
if exec_info['ExitCode'] != 0:
|
|
303
|
+
error_msg = (
|
|
304
|
+
f"Failed to install dependencies in Docker: "
|
|
305
|
+
f"{log.decode('utf-8', errors='ignore')}"
|
|
306
|
+
)
|
|
307
|
+
logger.error(error_msg)
|
|
308
|
+
raise RuntimeError(error_msg)
|
|
309
|
+
|
|
310
|
+
logger.info(
|
|
311
|
+
"Successfully installed all dependencies in Docker."
|
|
312
|
+
)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
if not isinstance(e, RuntimeError):
|
|
315
|
+
logger.error(f"Docker dependency installation error: {e}")
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
f"Docker dependency installation error: {e}"
|
|
318
|
+
) from e
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
else:
|
|
322
|
+
pip_cmd = [
|
|
323
|
+
self.python_executable,
|
|
324
|
+
"-m",
|
|
325
|
+
"pip",
|
|
326
|
+
"install",
|
|
327
|
+
"--upgrade",
|
|
328
|
+
*self.install_dependencies,
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
subprocess.run(
|
|
333
|
+
pip_cmd,
|
|
334
|
+
check=True,
|
|
335
|
+
cwd=self.working_dir,
|
|
336
|
+
capture_output=True,
|
|
337
|
+
text=True,
|
|
338
|
+
timeout=300, # 5 minutes timeout for installation
|
|
339
|
+
)
|
|
340
|
+
logger.info("Successfully installed all dependencies.")
|
|
341
|
+
except subprocess.CalledProcessError as e:
|
|
342
|
+
logger.error(f"Failed to install dependencies: {e.stderr}")
|
|
343
|
+
raise RuntimeError(
|
|
344
|
+
f"Failed to install dependencies: {e.stderr}"
|
|
345
|
+
) from e
|
|
346
|
+
except subprocess.TimeoutExpired:
|
|
347
|
+
logger.error(
|
|
348
|
+
"Dependency installation timed out after 5 minutes"
|
|
349
|
+
)
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
"Dependency installation timed out after 5 minutes"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _setup_initial_environment(self):
|
|
355
|
+
r"""Set up an initial environment with Python 3.10."""
|
|
356
|
+
self.initial_env_path = os.path.join(self.working_dir, ".initial_env")
|
|
357
|
+
|
|
358
|
+
def update_callback(msg: str):
|
|
359
|
+
logger.info(f"[ENV INIT] {msg.strip()}")
|
|
360
|
+
|
|
361
|
+
# Try to ensure uv is available first
|
|
362
|
+
success, uv_path = ensure_uv_available(update_callback)
|
|
363
|
+
|
|
364
|
+
if success and uv_path:
|
|
365
|
+
success = setup_initial_env_with_uv(
|
|
366
|
+
self.initial_env_path,
|
|
367
|
+
uv_path,
|
|
368
|
+
self.working_dir,
|
|
369
|
+
update_callback,
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
update_callback(
|
|
373
|
+
"Falling back to standard venv for environment setup\n"
|
|
374
|
+
)
|
|
375
|
+
success = setup_initial_env_with_venv(
|
|
376
|
+
self.initial_env_path, self.working_dir, update_callback
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if success:
|
|
380
|
+
# Update python executable to use the initial environment
|
|
381
|
+
if self.os_type == 'Windows':
|
|
382
|
+
self.python_executable = os.path.join(
|
|
383
|
+
self.initial_env_path, "Scripts", "python.exe"
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
self.python_executable = os.path.join(
|
|
387
|
+
self.initial_env_path, "bin", "python"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Check Node.js availability
|
|
391
|
+
check_nodejs_availability(update_callback)
|
|
392
|
+
else:
|
|
393
|
+
logger.info(
|
|
394
|
+
"[ENV INIT] Failed to create initial environment, "
|
|
395
|
+
"using system Python"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _get_venv_path(self) -> Optional[str]:
|
|
399
|
+
r"""Get the virtual environment path if available."""
|
|
400
|
+
if self.cloned_env_path and os.path.exists(self.cloned_env_path):
|
|
401
|
+
return self.cloned_env_path
|
|
402
|
+
elif self.initial_env_path and os.path.exists(self.initial_env_path):
|
|
403
|
+
return self.initial_env_path
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
def _write_to_log(self, log_file: str, content: str) -> None:
|
|
407
|
+
r"""Write content to log file with optional ANSI stripping.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
log_file (str): Path to the log file
|
|
411
|
+
content (str): Content to write
|
|
412
|
+
"""
|
|
413
|
+
# Convert ANSI escape sequences to plain text
|
|
414
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
415
|
+
f.write(_to_plain(content) + "\n")
|
|
416
|
+
|
|
417
|
+
def _sanitize_command(self, command: str) -> tuple[bool, str]:
|
|
418
|
+
r"""A comprehensive command sanitizer for both local and
|
|
419
|
+
Docker backends."""
|
|
420
|
+
return sanitize_command(
|
|
421
|
+
command=command,
|
|
422
|
+
use_docker_backend=self.use_docker_backend,
|
|
423
|
+
safe_mode=self.safe_mode,
|
|
424
|
+
working_dir=self.working_dir,
|
|
425
|
+
allowed_commands=self.allowed_commands,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def _start_output_reader_thread(self, session_id: str):
|
|
429
|
+
r"""Starts a thread to read stdout from a non-blocking process."""
|
|
430
|
+
with self._session_lock:
|
|
431
|
+
session = self.shell_sessions[session_id]
|
|
432
|
+
|
|
433
|
+
def reader():
|
|
434
|
+
def safe_put(data: str) -> None:
|
|
435
|
+
"""Put data to queue, dropping if full to prevent blocking."""
|
|
436
|
+
try:
|
|
437
|
+
session["output_stream"].put_nowait(data)
|
|
438
|
+
except Full:
|
|
439
|
+
# Queue is full, log warning and continue
|
|
440
|
+
# Data is still written to log file, so not lost
|
|
441
|
+
logger.warning(
|
|
442
|
+
f"[SESSION {session_id}] Output queue full, "
|
|
443
|
+
f"dropping data (still logged to file)"
|
|
444
|
+
)
|
|
445
|
+
return
|
|
446
|
+
# Notify waiters that new output is available
|
|
447
|
+
# Done outside try-except to avoid catching unrelated
|
|
448
|
+
# exceptions
|
|
449
|
+
with self._output_condition:
|
|
450
|
+
self._output_condition.notify_all()
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
if session["backend"] == "local":
|
|
454
|
+
# For local processes, read line by line from stdout
|
|
455
|
+
try:
|
|
456
|
+
for line in iter(
|
|
457
|
+
session["process"].stdout.readline, ''
|
|
458
|
+
):
|
|
459
|
+
self._write_to_log(session["log_file"], line)
|
|
460
|
+
safe_put(line)
|
|
461
|
+
finally:
|
|
462
|
+
session["process"].stdout.close()
|
|
463
|
+
elif session["backend"] == "docker":
|
|
464
|
+
# For Docker, read from the raw socket
|
|
465
|
+
socket = session["process"]._sock
|
|
466
|
+
while True:
|
|
467
|
+
# Check if the socket is still open before reading
|
|
468
|
+
if socket.fileno() == -1:
|
|
469
|
+
break
|
|
470
|
+
try:
|
|
471
|
+
ready, _, _ = select.select([socket], [], [], 0.1)
|
|
472
|
+
except (ValueError, OSError):
|
|
473
|
+
# Socket may have been closed by another thread
|
|
474
|
+
break
|
|
475
|
+
if ready:
|
|
476
|
+
data = socket.recv(4096)
|
|
477
|
+
if not data:
|
|
478
|
+
break
|
|
479
|
+
decoded_data = data.decode(
|
|
480
|
+
'utf-8', errors='ignore'
|
|
481
|
+
)
|
|
482
|
+
self._write_to_log(
|
|
483
|
+
session["log_file"], decoded_data
|
|
484
|
+
)
|
|
485
|
+
safe_put(decoded_data)
|
|
486
|
+
# Check if the process is still running
|
|
487
|
+
if not self.docker_api_client.exec_inspect(
|
|
488
|
+
session["exec_id"]
|
|
489
|
+
)['Running']:
|
|
490
|
+
break
|
|
491
|
+
except Exception as e:
|
|
492
|
+
# Log the exception for diagnosis and store it on the session
|
|
493
|
+
logger.exception(f"[SESSION {session_id}] Reader thread error")
|
|
494
|
+
try:
|
|
495
|
+
with self._session_lock:
|
|
496
|
+
if session_id in self.shell_sessions:
|
|
497
|
+
self.shell_sessions[session_id]["error"] = str(e)
|
|
498
|
+
except Exception as cleanup_error:
|
|
499
|
+
logger.warning(
|
|
500
|
+
f"[SESSION {session_id}] Failed to store error state: "
|
|
501
|
+
f"{cleanup_error}"
|
|
502
|
+
)
|
|
503
|
+
finally:
|
|
504
|
+
try:
|
|
505
|
+
with self._output_condition:
|
|
506
|
+
if session_id in self.shell_sessions:
|
|
507
|
+
self.shell_sessions[session_id]["running"] = False
|
|
508
|
+
# Notify waiters that session has terminated
|
|
509
|
+
self._output_condition.notify_all()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
thread = threading.Thread(target=reader, daemon=True)
|
|
514
|
+
thread.start()
|
|
515
|
+
|
|
516
|
+
def _collect_output_until_idle(
|
|
517
|
+
self,
|
|
518
|
+
id: str,
|
|
519
|
+
idle_duration: float = 0.5,
|
|
520
|
+
max_wait: float = 5.0,
|
|
521
|
+
) -> str:
|
|
522
|
+
r"""Collects output from a session until it's idle or a max wait time
|
|
523
|
+
is reached.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
id (str): The session ID.
|
|
527
|
+
idle_duration (float): How long the stream must be empty to be
|
|
528
|
+
considered idle.(default: 0.5)
|
|
529
|
+
max_wait (float): The maximum total time to wait for the process
|
|
530
|
+
to go idle. (default: 5.0)
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
str: The collected output. If max_wait is reached while
|
|
534
|
+
the process is still outputting, a warning is appended.
|
|
535
|
+
"""
|
|
536
|
+
with self._session_lock:
|
|
537
|
+
if id not in self.shell_sessions:
|
|
538
|
+
return f"Error: No session found with ID '{id}'."
|
|
539
|
+
|
|
540
|
+
output_parts: List[str] = []
|
|
541
|
+
last_output_time = time.time()
|
|
542
|
+
start_time = last_output_time
|
|
543
|
+
|
|
544
|
+
while True:
|
|
545
|
+
elapsed = time.time() - start_time
|
|
546
|
+
if elapsed >= max_wait:
|
|
547
|
+
break
|
|
548
|
+
|
|
549
|
+
new_output = self.shell_view(id)
|
|
550
|
+
|
|
551
|
+
# Check for terminal state messages from shell_view
|
|
552
|
+
if "--- SESSION TERMINATED ---" in new_output:
|
|
553
|
+
# Append the final output before the termination message
|
|
554
|
+
final_part = new_output.replace(
|
|
555
|
+
"--- SESSION TERMINATED ---", ""
|
|
556
|
+
).strip()
|
|
557
|
+
if final_part:
|
|
558
|
+
output_parts.append(final_part)
|
|
559
|
+
# Session is dead, return what we have plus the message
|
|
560
|
+
return "".join(output_parts) + "\n--- SESSION TERMINATED ---"
|
|
561
|
+
|
|
562
|
+
if new_output.startswith("Error: No session found"):
|
|
563
|
+
return new_output
|
|
564
|
+
|
|
565
|
+
# Check if this is actual output or just the idle message
|
|
566
|
+
if new_output and not new_output.startswith("[No new output]"):
|
|
567
|
+
output_parts.append(new_output)
|
|
568
|
+
last_output_time = time.time() # Reset idle timer
|
|
569
|
+
else:
|
|
570
|
+
# No new output, check if we've been idle long enough
|
|
571
|
+
idle_time = time.time() - last_output_time
|
|
572
|
+
if idle_time >= idle_duration:
|
|
573
|
+
# Process is idle, success
|
|
574
|
+
return "".join(output_parts)
|
|
575
|
+
|
|
576
|
+
# Calculate remaining time for idle and max_wait
|
|
577
|
+
time_until_idle = idle_duration - (time.time() - last_output_time)
|
|
578
|
+
time_until_max = max_wait - (time.time() - start_time)
|
|
579
|
+
# Wait for the shorter of: idle timeout, max timeout, or a
|
|
580
|
+
# reasonable check interval
|
|
581
|
+
wait_time = max(0.0, min(time_until_idle, time_until_max))
|
|
582
|
+
|
|
583
|
+
if wait_time > 0:
|
|
584
|
+
# Use condition variable to wait efficiently
|
|
585
|
+
# Wake up when new output arrives or timeout expires
|
|
586
|
+
with self._output_condition:
|
|
587
|
+
self._output_condition.wait(timeout=wait_time)
|
|
588
|
+
|
|
589
|
+
# If we exit the loop, it means max_wait was reached.
|
|
590
|
+
# Check one last time for any final output.
|
|
591
|
+
final_output = self.shell_view(id)
|
|
592
|
+
if final_output and not final_output.startswith("[No new output]"):
|
|
593
|
+
output_parts.append(final_output)
|
|
594
|
+
|
|
595
|
+
warning_message = (
|
|
596
|
+
"\n--- WARNING: Process is still actively outputting "
|
|
597
|
+
"after max wait time. Consider waiting before "
|
|
598
|
+
"sending the next command. ---"
|
|
599
|
+
)
|
|
600
|
+
return "".join(output_parts) + warning_message
|
|
601
|
+
|
|
602
|
+
def shell_exec(
|
|
603
|
+
self,
|
|
604
|
+
id: str,
|
|
605
|
+
command: str,
|
|
606
|
+
block: bool = True,
|
|
607
|
+
timeout: float = 20.0,
|
|
608
|
+
) -> str:
|
|
609
|
+
r"""Executes a shell command in blocking or non-blocking mode.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
id (str): A unique identifier for the command's session. This ID is
|
|
613
|
+
used to interact with non-blocking processes.
|
|
614
|
+
command (str): The shell command to execute.
|
|
615
|
+
block (bool, optional): Determines the execution mode. Defaults to
|
|
616
|
+
True. If `True` (blocking mode), the function waits for the
|
|
617
|
+
command to complete and returns the full output. Use this for
|
|
618
|
+
most commands. If `False` (non-blocking mode), the function
|
|
619
|
+
starts the command in the background. Use this only for
|
|
620
|
+
interactive sessions or long-running tasks, or servers.
|
|
621
|
+
timeout (float, optional): The maximum time in seconds to
|
|
622
|
+
wait for the command to complete in blocking mode. If the
|
|
623
|
+
command does not complete within the timeout, it will be
|
|
624
|
+
converted to a tracked background session (process keeps
|
|
625
|
+
running without restart). You can then use `shell_view(id)`
|
|
626
|
+
to check output, or `shell_kill_process(id)` to terminate it.
|
|
627
|
+
This parameter is ignored in non-blocking mode.
|
|
628
|
+
(default: :obj:`20`)
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
str: The output of the command execution, which varies by mode.
|
|
632
|
+
In blocking mode, returns the complete standard output and
|
|
633
|
+
standard error from the command.
|
|
634
|
+
In non-blocking mode, returns a confirmation message with the
|
|
635
|
+
session `id`. To interact with the background process, use
|
|
636
|
+
other functions: `shell_view(id)` to see output,
|
|
637
|
+
`shell_write_to_process(id, "input")` to send input, and
|
|
638
|
+
`shell_kill_process(id)` to terminate.
|
|
639
|
+
"""
|
|
640
|
+
if self.safe_mode:
|
|
641
|
+
is_safe, message = self._sanitize_command(command)
|
|
642
|
+
if not is_safe:
|
|
643
|
+
return f"Error: {message}"
|
|
644
|
+
command = message
|
|
645
|
+
|
|
646
|
+
if self.use_docker_backend:
|
|
647
|
+
# For Docker, we always run commands in a shell
|
|
648
|
+
# to support complex commands.
|
|
649
|
+
# Use shlex.quote to properly escape the command string.
|
|
650
|
+
command = f'bash -c {shlex.quote(command)}'
|
|
651
|
+
else:
|
|
652
|
+
# For local execution, activate virtual environment if available
|
|
653
|
+
env_path = self._get_venv_path()
|
|
654
|
+
if env_path:
|
|
655
|
+
if self.os_type == 'Windows':
|
|
656
|
+
activate = os.path.join(
|
|
657
|
+
env_path, "Scripts", "activate.bat"
|
|
658
|
+
)
|
|
659
|
+
command = f'call "{activate}" && {command}'
|
|
660
|
+
else:
|
|
661
|
+
activate = os.path.join(env_path, "bin", "activate")
|
|
662
|
+
command = f'. "{activate}" && {command}'
|
|
663
|
+
|
|
664
|
+
session_id = id
|
|
665
|
+
|
|
666
|
+
if block:
|
|
667
|
+
# --- BLOCKING EXECUTION ---
|
|
668
|
+
log_entry = (
|
|
669
|
+
f"--- Executing blocking command at "
|
|
670
|
+
f"{time.ctime()} ---\n> {command}\n"
|
|
671
|
+
)
|
|
672
|
+
output = ""
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
if not self.use_docker_backend:
|
|
676
|
+
env_vars = os.environ.copy()
|
|
677
|
+
env_vars["PYTHONUNBUFFERED"] = "1"
|
|
678
|
+
proc = subprocess.Popen(
|
|
679
|
+
command,
|
|
680
|
+
stdout=subprocess.PIPE,
|
|
681
|
+
stderr=subprocess.STDOUT,
|
|
682
|
+
stdin=subprocess.PIPE,
|
|
683
|
+
shell=True,
|
|
684
|
+
text=True,
|
|
685
|
+
cwd=self.working_dir,
|
|
686
|
+
encoding="utf-8",
|
|
687
|
+
env=env_vars,
|
|
688
|
+
)
|
|
689
|
+
try:
|
|
690
|
+
stdout, _ = proc.communicate(timeout=timeout)
|
|
691
|
+
output = stdout or ""
|
|
692
|
+
except subprocess.TimeoutExpired as e:
|
|
693
|
+
if e.stdout:
|
|
694
|
+
partial_output = (
|
|
695
|
+
e.stdout.decode("utf-8", errors="ignore")
|
|
696
|
+
if isinstance(e.stdout, bytes)
|
|
697
|
+
else e.stdout
|
|
698
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
partial_output = ""
|
|
701
|
+
|
|
702
|
+
session_log_file = os.path.join(
|
|
703
|
+
self.log_dir, f"session_{session_id}.log"
|
|
704
|
+
)
|
|
705
|
+
self._write_to_log(
|
|
706
|
+
session_log_file,
|
|
707
|
+
f"--- Blocking command timed out, converted to "
|
|
708
|
+
f"session at {time.ctime()} ---\n> {command}\n",
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# Pre-populate output queue with partial output
|
|
712
|
+
output_queue: Queue = Queue(maxsize=10000)
|
|
713
|
+
if partial_output:
|
|
714
|
+
output_queue.put(partial_output)
|
|
715
|
+
|
|
716
|
+
with self._session_lock:
|
|
717
|
+
self.shell_sessions[session_id] = {
|
|
718
|
+
"id": session_id,
|
|
719
|
+
"process": proc,
|
|
720
|
+
"output_stream": output_queue,
|
|
721
|
+
"command_history": [command],
|
|
722
|
+
"running": True,
|
|
723
|
+
"log_file": session_log_file,
|
|
724
|
+
"backend": "local",
|
|
725
|
+
"timeout_converted": True,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Start reader thread to capture ongoing output
|
|
729
|
+
self._start_output_reader_thread(session_id)
|
|
730
|
+
|
|
731
|
+
self._write_to_log(
|
|
732
|
+
self.blocking_log_file, log_entry + "\n"
|
|
733
|
+
)
|
|
734
|
+
return (
|
|
735
|
+
f"Command did not complete within {timeout} "
|
|
736
|
+
f"seconds. Process continues in background as "
|
|
737
|
+
f"session '{session_id}'.\n\n"
|
|
738
|
+
f"You can use:\n"
|
|
739
|
+
f" - shell_view('{session_id}') - get output\n"
|
|
740
|
+
f" - shell_kill_process('{session_id}') - "
|
|
741
|
+
f"terminate"
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
# DOCKER BLOCKING with timeout
|
|
745
|
+
assert (
|
|
746
|
+
self.docker_workdir is not None
|
|
747
|
+
) # Docker backend always has workdir
|
|
748
|
+
exec_instance = self.docker_api_client.exec_create(
|
|
749
|
+
self.container.id, command, workdir=self.docker_workdir
|
|
750
|
+
)
|
|
751
|
+
exec_id = exec_instance['Id']
|
|
752
|
+
|
|
753
|
+
# Use thread to implement timeout for docker exec
|
|
754
|
+
result_container: Dict[str, Any] = {}
|
|
755
|
+
|
|
756
|
+
def run_exec():
|
|
757
|
+
try:
|
|
758
|
+
result_container['output'] = (
|
|
759
|
+
self.docker_api_client.exec_start(exec_id)
|
|
760
|
+
)
|
|
761
|
+
except Exception as e:
|
|
762
|
+
result_container['error'] = e
|
|
763
|
+
|
|
764
|
+
exec_thread = threading.Thread(target=run_exec)
|
|
765
|
+
exec_thread.start()
|
|
766
|
+
exec_thread.join(timeout=timeout)
|
|
767
|
+
|
|
768
|
+
if exec_thread.is_alive():
|
|
769
|
+
# Timeout occurred - convert to tracked session
|
|
770
|
+
# so agent can monitor or kill the process.
|
|
771
|
+
# The exec_thread continues running in background and
|
|
772
|
+
# will eventually write output to result_container.
|
|
773
|
+
session_log_file = os.path.join(
|
|
774
|
+
self.log_dir, f"session_{session_id}.log"
|
|
775
|
+
)
|
|
776
|
+
self._write_to_log(
|
|
777
|
+
session_log_file,
|
|
778
|
+
f"--- Blocking command timed out, converted to "
|
|
779
|
+
f"session at {time.ctime()} ---\n> {command}\n",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
with self._session_lock:
|
|
783
|
+
self.shell_sessions[session_id] = {
|
|
784
|
+
"id": session_id,
|
|
785
|
+
"process": None, # No socket for blocking exec
|
|
786
|
+
"exec_id": exec_id,
|
|
787
|
+
"exec_thread": exec_thread, # Thread reference
|
|
788
|
+
"result_container": result_container, # Shared
|
|
789
|
+
"output_stream": Queue(maxsize=10000),
|
|
790
|
+
"command_history": [command],
|
|
791
|
+
"running": True,
|
|
792
|
+
"log_file": session_log_file,
|
|
793
|
+
"backend": "docker",
|
|
794
|
+
"timeout_converted": True, # Mark as converted
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
self._write_to_log(
|
|
798
|
+
self.blocking_log_file, log_entry + "\n"
|
|
799
|
+
)
|
|
800
|
+
return (
|
|
801
|
+
f"Command did not complete within {timeout} "
|
|
802
|
+
f"seconds. Process continues in background as "
|
|
803
|
+
f"session '{session_id}'.\n\n"
|
|
804
|
+
f"You can use:\n"
|
|
805
|
+
f" - shell_view('{session_id}') - get output\n"
|
|
806
|
+
f" - shell_kill_process('{session_id}') - "
|
|
807
|
+
f"terminate"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if 'error' in result_container:
|
|
811
|
+
raise result_container['error']
|
|
812
|
+
|
|
813
|
+
output = result_container['output'].decode(
|
|
814
|
+
'utf-8', errors='ignore'
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
log_entry += f"--- Output ---\n{output}\n"
|
|
818
|
+
if output.strip():
|
|
819
|
+
return _to_plain(output)
|
|
820
|
+
else:
|
|
821
|
+
return "Command executed successfully (no output)."
|
|
822
|
+
except Exception as e:
|
|
823
|
+
error_msg = f"Error executing command: {e}"
|
|
824
|
+
log_entry += f"--- Error ---\n{error_msg}\n"
|
|
825
|
+
return error_msg
|
|
826
|
+
finally:
|
|
827
|
+
self._write_to_log(self.blocking_log_file, log_entry + "\n")
|
|
828
|
+
else:
|
|
829
|
+
# --- NON-BLOCKING EXECUTION ---
|
|
830
|
+
session_log_file = os.path.join(
|
|
831
|
+
self.log_dir, f"session_{session_id}.log"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# PYTHONUNBUFFERED=1 for real-time output
|
|
835
|
+
# Without this, Python subprocesses buffer output (4KB buffer)
|
|
836
|
+
# and shell_view() won't see output until buffer fills or process
|
|
837
|
+
# exits
|
|
838
|
+
env_vars = os.environ.copy()
|
|
839
|
+
env_vars["PYTHONUNBUFFERED"] = "1"
|
|
840
|
+
docker_env = {"PYTHONUNBUFFERED": "1"}
|
|
841
|
+
|
|
842
|
+
# Check and create session atomically to prevent race condition
|
|
843
|
+
with self._session_lock:
|
|
844
|
+
if session_id in self.shell_sessions:
|
|
845
|
+
existing_session = self.shell_sessions[session_id]
|
|
846
|
+
if existing_session.get("running", False):
|
|
847
|
+
return (
|
|
848
|
+
f"Error: Session '{session_id}' already exists "
|
|
849
|
+
f"and is running. Use a different ID or kill "
|
|
850
|
+
f"the existing session first."
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# Create session entry while holding the lock
|
|
854
|
+
self.shell_sessions[session_id] = {
|
|
855
|
+
"id": session_id,
|
|
856
|
+
"process": None,
|
|
857
|
+
# Limit queue size to prevent memory exhaustion
|
|
858
|
+
# (~100MB with 10k items of ~10KB each)
|
|
859
|
+
"output_stream": Queue(maxsize=10000),
|
|
860
|
+
"command_history": [command],
|
|
861
|
+
"running": True,
|
|
862
|
+
"log_file": session_log_file,
|
|
863
|
+
"backend": "docker"
|
|
864
|
+
if self.use_docker_backend
|
|
865
|
+
else "local",
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
self._write_to_log(
|
|
869
|
+
session_log_file,
|
|
870
|
+
f"--- Starting non-blocking session at {time.ctime()} ---\n"
|
|
871
|
+
f"> {command}\n",
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
process = None
|
|
875
|
+
exec_socket = None
|
|
876
|
+
try:
|
|
877
|
+
if not self.use_docker_backend:
|
|
878
|
+
process = subprocess.Popen(
|
|
879
|
+
command,
|
|
880
|
+
stdin=subprocess.PIPE,
|
|
881
|
+
stdout=subprocess.PIPE,
|
|
882
|
+
stderr=subprocess.STDOUT,
|
|
883
|
+
shell=True,
|
|
884
|
+
text=True,
|
|
885
|
+
cwd=self.working_dir,
|
|
886
|
+
encoding="utf-8",
|
|
887
|
+
env=env_vars,
|
|
888
|
+
)
|
|
889
|
+
with self._session_lock:
|
|
890
|
+
self.shell_sessions[session_id]["process"] = process
|
|
891
|
+
else:
|
|
892
|
+
assert (
|
|
893
|
+
self.docker_workdir is not None
|
|
894
|
+
) # Docker backend always has workdir
|
|
895
|
+
exec_instance = self.docker_api_client.exec_create(
|
|
896
|
+
self.container.id,
|
|
897
|
+
command,
|
|
898
|
+
stdin=True,
|
|
899
|
+
tty=True,
|
|
900
|
+
workdir=self.docker_workdir,
|
|
901
|
+
environment=docker_env,
|
|
902
|
+
)
|
|
903
|
+
exec_id = exec_instance['Id']
|
|
904
|
+
exec_socket = self.docker_api_client.exec_start(
|
|
905
|
+
exec_id, tty=True, stream=True, socket=True
|
|
906
|
+
)
|
|
907
|
+
with self._session_lock:
|
|
908
|
+
self.shell_sessions[session_id]["process"] = (
|
|
909
|
+
exec_socket
|
|
910
|
+
)
|
|
911
|
+
self.shell_sessions[session_id]["exec_id"] = exec_id
|
|
912
|
+
|
|
913
|
+
self._start_output_reader_thread(session_id)
|
|
914
|
+
|
|
915
|
+
# Return immediately with session ID and instructions
|
|
916
|
+
return (
|
|
917
|
+
f"Session '{session_id}' started.\n\n"
|
|
918
|
+
f"You could use:\n"
|
|
919
|
+
f" - shell_view('{session_id}') - get output\n"
|
|
920
|
+
f" - shell_write_to_process('{session_id}', '<input>')"
|
|
921
|
+
f" - send input\n"
|
|
922
|
+
f" - shell_kill_process('{session_id}') - terminate"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
except Exception as e:
|
|
926
|
+
# Clean up resources on failure
|
|
927
|
+
if process is not None:
|
|
928
|
+
try:
|
|
929
|
+
process.terminate()
|
|
930
|
+
except Exception:
|
|
931
|
+
pass
|
|
932
|
+
if exec_socket is not None:
|
|
933
|
+
try:
|
|
934
|
+
exec_socket.close()
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
with self._session_lock:
|
|
939
|
+
if session_id in self.shell_sessions:
|
|
940
|
+
self.shell_sessions[session_id]["running"] = False
|
|
941
|
+
error_msg = f"Error starting non-blocking command: {e}"
|
|
942
|
+
self._write_to_log(
|
|
943
|
+
session_log_file, f"--- Error ---\n{error_msg}\n"
|
|
944
|
+
)
|
|
945
|
+
return error_msg
|
|
946
|
+
|
|
947
|
+
def shell_write_to_process(self, id: str, command: str) -> str:
|
|
948
|
+
r"""This function sends command to a running non-blocking
|
|
949
|
+
process and returns the resulting output after the process
|
|
950
|
+
becomes idle again. A newline \n is automatically appended
|
|
951
|
+
to the input command.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
id (str): The unique session ID of the non-blocking process.
|
|
955
|
+
command (str): The text to write to the process's standard input.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
str: The output from the process after the command is sent.
|
|
959
|
+
"""
|
|
960
|
+
with self._session_lock:
|
|
961
|
+
if (
|
|
962
|
+
id not in self.shell_sessions
|
|
963
|
+
or not self.shell_sessions[id]["running"]
|
|
964
|
+
):
|
|
965
|
+
return (
|
|
966
|
+
f"Error: No active non-blocking "
|
|
967
|
+
f"session found with ID '{id}'."
|
|
968
|
+
)
|
|
969
|
+
session = self.shell_sessions[id]
|
|
970
|
+
|
|
971
|
+
# Flush any lingering output from previous commands.
|
|
972
|
+
self._collect_output_until_idle(id, idle_duration=0.3, max_wait=2.0)
|
|
973
|
+
|
|
974
|
+
with self._session_lock:
|
|
975
|
+
session["command_history"].append(command)
|
|
976
|
+
log_file = session["log_file"]
|
|
977
|
+
backend = session["backend"]
|
|
978
|
+
process = session["process"]
|
|
979
|
+
|
|
980
|
+
# Log command to the raw log file
|
|
981
|
+
self._write_to_log(log_file, f"> {command}\n")
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
if backend == "local":
|
|
985
|
+
process.stdin.write(command + '\n')
|
|
986
|
+
process.stdin.flush()
|
|
987
|
+
else: # docker
|
|
988
|
+
socket = process._sock
|
|
989
|
+
socket.sendall((command + '\n').encode('utf-8'))
|
|
990
|
+
|
|
991
|
+
# Wait for and collect the new output
|
|
992
|
+
output = self._collect_output_until_idle(id)
|
|
993
|
+
|
|
994
|
+
if output.strip():
|
|
995
|
+
return output
|
|
996
|
+
else:
|
|
997
|
+
return (
|
|
998
|
+
f"Input sent to session '{id}' successfully (no output)."
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
return f"Error writing to session '{id}': {e}"
|
|
1003
|
+
|
|
1004
|
+
def shell_view(self, id: str) -> str:
|
|
1005
|
+
r"""Retrieves new output from a non-blocking session.
|
|
1006
|
+
|
|
1007
|
+
This function returns only NEW output since the last call. It does NOT
|
|
1008
|
+
wait or block - it returns immediately with whatever is available.
|
|
1009
|
+
|
|
1010
|
+
Args:
|
|
1011
|
+
id (str): The unique session ID of the non-blocking process.
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
str: New output if available, or a status message.
|
|
1015
|
+
"""
|
|
1016
|
+
with self._session_lock:
|
|
1017
|
+
if id not in self.shell_sessions:
|
|
1018
|
+
return f"Error: No session found with ID '{id}'."
|
|
1019
|
+
session = self.shell_sessions[id]
|
|
1020
|
+
is_running = session["running"]
|
|
1021
|
+
|
|
1022
|
+
# If session is terminated, drain the queue and return
|
|
1023
|
+
if not is_running:
|
|
1024
|
+
final_output = []
|
|
1025
|
+
try:
|
|
1026
|
+
while True:
|
|
1027
|
+
final_output.append(session["output_stream"].get_nowait())
|
|
1028
|
+
except Empty:
|
|
1029
|
+
pass
|
|
1030
|
+
|
|
1031
|
+
if final_output:
|
|
1032
|
+
return "".join(final_output) + "\n\n--- SESSION TERMINATED ---"
|
|
1033
|
+
else:
|
|
1034
|
+
return "--- SESSION TERMINATED (no new output) ---"
|
|
1035
|
+
|
|
1036
|
+
# For running session, check for new output
|
|
1037
|
+
output = []
|
|
1038
|
+
try:
|
|
1039
|
+
while True:
|
|
1040
|
+
output.append(session["output_stream"].get_nowait())
|
|
1041
|
+
except Empty:
|
|
1042
|
+
pass
|
|
1043
|
+
|
|
1044
|
+
if output:
|
|
1045
|
+
return "".join(output)
|
|
1046
|
+
else:
|
|
1047
|
+
# For timeout-converted Docker sessions, check thread and output
|
|
1048
|
+
if (
|
|
1049
|
+
session.get("timeout_converted")
|
|
1050
|
+
and session.get("backend") == "docker"
|
|
1051
|
+
):
|
|
1052
|
+
exec_thread = session.get("exec_thread")
|
|
1053
|
+
result_container = session.get("result_container", {})
|
|
1054
|
+
|
|
1055
|
+
# Check if the background thread has completed
|
|
1056
|
+
if exec_thread and not exec_thread.is_alive():
|
|
1057
|
+
# Thread finished - get output from result_container
|
|
1058
|
+
with self._session_lock:
|
|
1059
|
+
if id in self.shell_sessions:
|
|
1060
|
+
self.shell_sessions[id]["running"] = False
|
|
1061
|
+
|
|
1062
|
+
if 'output' in result_container:
|
|
1063
|
+
completed_output = result_container['output'].decode(
|
|
1064
|
+
'utf-8', errors='ignore'
|
|
1065
|
+
)
|
|
1066
|
+
# Write to log file
|
|
1067
|
+
self._write_to_log(
|
|
1068
|
+
session["log_file"], completed_output
|
|
1069
|
+
)
|
|
1070
|
+
return (
|
|
1071
|
+
f"{_to_plain(completed_output)}\n\n"
|
|
1072
|
+
f"--- SESSION COMPLETED ---"
|
|
1073
|
+
)
|
|
1074
|
+
elif 'error' in result_container:
|
|
1075
|
+
return (
|
|
1076
|
+
f"--- SESSION FAILED ---\n"
|
|
1077
|
+
f"Error: {result_container['error']}"
|
|
1078
|
+
)
|
|
1079
|
+
else:
|
|
1080
|
+
return "--- SESSION COMPLETED (no output) ---"
|
|
1081
|
+
else:
|
|
1082
|
+
# Thread still running
|
|
1083
|
+
return (
|
|
1084
|
+
"[Process still running]\n"
|
|
1085
|
+
"Command is executing in background. "
|
|
1086
|
+
"Check again later for output.\n"
|
|
1087
|
+
"Use shell_kill_process() to terminate if needed."
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
# No new output - guide the agent
|
|
1091
|
+
return (
|
|
1092
|
+
"[No new output]\n"
|
|
1093
|
+
"Session is running but idle. Actions could take:\n"
|
|
1094
|
+
" - For interactive sessions: Send input "
|
|
1095
|
+
"with shell_write_to_process()\n"
|
|
1096
|
+
" - For long tasks: Check again later (don't poll "
|
|
1097
|
+
"too frequently)"
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
@manual_timeout
|
|
1101
|
+
def shell_kill_process(self, id: str) -> str:
|
|
1102
|
+
r"""This function forcibly terminates a running non-blocking process.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
id (str): The unique session ID of the process to kill.
|
|
1106
|
+
|
|
1107
|
+
Returns:
|
|
1108
|
+
str: A confirmation message indicating the process was terminated.
|
|
1109
|
+
"""
|
|
1110
|
+
with self._session_lock:
|
|
1111
|
+
if (
|
|
1112
|
+
id not in self.shell_sessions
|
|
1113
|
+
or not self.shell_sessions[id]["running"]
|
|
1114
|
+
):
|
|
1115
|
+
return f"Error: No active session found with ID '{id}'."
|
|
1116
|
+
session = self.shell_sessions[id]
|
|
1117
|
+
try:
|
|
1118
|
+
if session["backend"] == "local":
|
|
1119
|
+
session["process"].terminate()
|
|
1120
|
+
time.sleep(0.5)
|
|
1121
|
+
if session["process"].poll() is None:
|
|
1122
|
+
session["process"].kill()
|
|
1123
|
+
# Ensure stdio streams are closed to unblock reader thread
|
|
1124
|
+
try:
|
|
1125
|
+
if getattr(session["process"], "stdin", None):
|
|
1126
|
+
session["process"].stdin.close()
|
|
1127
|
+
except Exception:
|
|
1128
|
+
pass
|
|
1129
|
+
try:
|
|
1130
|
+
if getattr(session["process"], "stdout", None):
|
|
1131
|
+
session["process"].stdout.close()
|
|
1132
|
+
except Exception:
|
|
1133
|
+
pass
|
|
1134
|
+
else: # docker
|
|
1135
|
+
# Check if this is a timeout-converted session (no socket)
|
|
1136
|
+
if session.get("timeout_converted") and session.get("exec_id"):
|
|
1137
|
+
# Kill the process using Docker exec PID
|
|
1138
|
+
exec_id = session["exec_id"]
|
|
1139
|
+
try:
|
|
1140
|
+
exec_info = self.docker_api_client.exec_inspect(
|
|
1141
|
+
exec_id
|
|
1142
|
+
)
|
|
1143
|
+
pid = exec_info.get('Pid')
|
|
1144
|
+
if pid and exec_info.get('Running', False):
|
|
1145
|
+
# Kill the process inside the container
|
|
1146
|
+
kill_cmd = f'kill -9 {pid}'
|
|
1147
|
+
kill_exec = self.docker_api_client.exec_create(
|
|
1148
|
+
self.container.id, kill_cmd
|
|
1149
|
+
)
|
|
1150
|
+
self.docker_api_client.exec_start(kill_exec['Id'])
|
|
1151
|
+
except Exception as kill_err:
|
|
1152
|
+
logger.warning(
|
|
1153
|
+
f"[SESSION {id}] Failed to kill Docker exec "
|
|
1154
|
+
f"process: {kill_err}"
|
|
1155
|
+
)
|
|
1156
|
+
elif session["process"] is not None:
|
|
1157
|
+
# Normal non-blocking session with socket
|
|
1158
|
+
session["process"].close()
|
|
1159
|
+
with self._session_lock:
|
|
1160
|
+
if id in self.shell_sessions:
|
|
1161
|
+
self.shell_sessions[id]["running"] = False
|
|
1162
|
+
return f"Process in session '{id}' has been terminated."
|
|
1163
|
+
except Exception as e:
|
|
1164
|
+
return f"Error killing process in session '{id}': {e}"
|
|
1165
|
+
|
|
1166
|
+
def shell_ask_user_for_help(self, id: str, prompt: str) -> str:
|
|
1167
|
+
r"""This function pauses execution and asks a human for help
|
|
1168
|
+
with an interactive session.
|
|
1169
|
+
|
|
1170
|
+
This method can handle different scenarios:
|
|
1171
|
+
1. If session exists: Shows session output and allows interaction
|
|
1172
|
+
2. If session doesn't exist: Creates a temporary session for help
|
|
1173
|
+
|
|
1174
|
+
Args:
|
|
1175
|
+
id (str): The session ID of the interactive process needing help.
|
|
1176
|
+
Can be empty string for general help without session context.
|
|
1177
|
+
prompt (str): The question or instruction from the LLM to show the
|
|
1178
|
+
human user (e.g., "The program is asking for a filename. Please
|
|
1179
|
+
enter 'config.json'.").
|
|
1180
|
+
|
|
1181
|
+
Returns:
|
|
1182
|
+
str: The output from the shell session after the user's command has
|
|
1183
|
+
been executed, or help information for general queries.
|
|
1184
|
+
"""
|
|
1185
|
+
# Use print for user-facing messages since this is an interactive
|
|
1186
|
+
# function that requires terminal input via input()
|
|
1187
|
+
print("\n" + "=" * 60)
|
|
1188
|
+
print("LLM Agent needs your help!")
|
|
1189
|
+
print(f"PROMPT: {prompt}")
|
|
1190
|
+
|
|
1191
|
+
# Case 1: Session doesn't exist - offer to create one
|
|
1192
|
+
if id not in self.shell_sessions:
|
|
1193
|
+
try:
|
|
1194
|
+
user_input = input("Your response: ").strip()
|
|
1195
|
+
if not user_input:
|
|
1196
|
+
return "No user response."
|
|
1197
|
+
else:
|
|
1198
|
+
print(f"Creating session '{id}' and executing command...")
|
|
1199
|
+
result = self.shell_exec(id, user_input, block=True)
|
|
1200
|
+
return (
|
|
1201
|
+
f"Session '{id}' created and "
|
|
1202
|
+
f"executed command:\n{result}"
|
|
1203
|
+
)
|
|
1204
|
+
except EOFError:
|
|
1205
|
+
return f"User input interrupted for session '{id}' creation."
|
|
1206
|
+
|
|
1207
|
+
# Case 2: Session exists - show context and interact
|
|
1208
|
+
else:
|
|
1209
|
+
# Get the latest output to show the user the current state
|
|
1210
|
+
last_output = self._collect_output_until_idle(id)
|
|
1211
|
+
last_output_display = (
|
|
1212
|
+
last_output.strip() if last_output.strip() else "(no output)"
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
print(f"SESSION: '{id}' (active)")
|
|
1216
|
+
print("=" * 60)
|
|
1217
|
+
print("--- LAST OUTPUT ---")
|
|
1218
|
+
print(last_output_display)
|
|
1219
|
+
print("-------------------")
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
user_input = input("Your input: ").strip()
|
|
1223
|
+
if not user_input:
|
|
1224
|
+
return f"User provided no input for session '{id}'."
|
|
1225
|
+
else:
|
|
1226
|
+
# Send input to the existing session
|
|
1227
|
+
return self.shell_write_to_process(id, user_input)
|
|
1228
|
+
except EOFError:
|
|
1229
|
+
return f"User input interrupted for session '{id}'."
|
|
1230
|
+
|
|
1231
|
+
def __enter__(self):
|
|
1232
|
+
r"""Context manager entry."""
|
|
1233
|
+
return self
|
|
1234
|
+
|
|
1235
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1236
|
+
r"""Context manager exit - clean up all sessions."""
|
|
1237
|
+
self.cleanup()
|
|
1238
|
+
return False
|
|
1239
|
+
|
|
1240
|
+
@manual_timeout
|
|
1241
|
+
def cleanup(self):
|
|
1242
|
+
r"""Clean up all active sessions."""
|
|
1243
|
+
with self._session_lock:
|
|
1244
|
+
session_ids = list(self.shell_sessions.keys())
|
|
1245
|
+
for session_id in session_ids:
|
|
1246
|
+
with self._session_lock:
|
|
1247
|
+
is_running = self.shell_sessions.get(session_id, {}).get(
|
|
1248
|
+
"running", False
|
|
1249
|
+
)
|
|
1250
|
+
if is_running:
|
|
1251
|
+
try:
|
|
1252
|
+
self.shell_kill_process(session_id)
|
|
1253
|
+
except Exception as e:
|
|
1254
|
+
logger.warning(
|
|
1255
|
+
f"Failed to kill session '{session_id}' "
|
|
1256
|
+
f"during cleanup: {e}"
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
@manual_timeout
|
|
1260
|
+
def __del__(self):
|
|
1261
|
+
r"""Fallback cleanup in destructor."""
|
|
1262
|
+
try:
|
|
1263
|
+
self.cleanup()
|
|
1264
|
+
except Exception:
|
|
1265
|
+
pass
|
|
1266
|
+
|
|
1267
|
+
def get_tools(self) -> List[FunctionTool]:
|
|
1268
|
+
r"""Returns a list of FunctionTool objects representing the functions
|
|
1269
|
+
in the toolkit.
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
List[FunctionTool]: A list of FunctionTool objects representing the
|
|
1273
|
+
functions in the toolkit.
|
|
1274
|
+
"""
|
|
1275
|
+
return [
|
|
1276
|
+
FunctionTool(self.shell_exec),
|
|
1277
|
+
FunctionTool(self.shell_view),
|
|
1278
|
+
FunctionTool(self.shell_write_to_process),
|
|
1279
|
+
FunctionTool(self.shell_kill_process),
|
|
1280
|
+
FunctionTool(self.shell_ask_user_for_help),
|
|
1281
|
+
]
|