From cae1a86f05c00c9b7c2ee58784a6ce46f23c3071 Mon Sep 17 00:00:00 2001 From: Spyder Rex Date: Sat, 14 Sep 2024 19:47:03 +0000 Subject: [PATCH] Reinit --- .env.template | 11 + .gitignore | 9 + LICENSE.txt | 21 + README.md | 73 ++ main.py | 207 ++++ requirements.txt | 200 ++++ squadai/__init__.py | 11 + squadai/__main__.py | 5 + squadai/agent.py | 415 ++++++++ squadai/agents/__init__.py | 6 + squadai/agents/agent_builder/__init__.py | 0 squadai/agents/agent_builder/base_agent.py | 272 +++++ .../base_agent_executor_mixin.py | 107 ++ .../agent_builder/utilities/__init__.py | 0 .../utilities/base_agent_tool.py | 86 ++ .../utilities/base_output_converter.py | 47 + .../utilities/base_token_process.py | 27 + squadai/agents/cache/__init__.py | 3 + squadai/agents/cache/cache_handler.py | 15 + squadai/agents/executor.py | 397 ++++++++ squadai/agents/parser.py | 121 +++ squadai/agents/tools_handler.py | 32 + squadai/cli/__init__.py | 0 squadai/cli/cli.py | 172 ++++ squadai/cli/create_pipeline.py | 107 ++ squadai/cli/create_squad.py | 71 ++ squadai/cli/evaluate_squad.py | 30 + squadai/cli/install_squad.py | 21 + squadai/cli/replay_from_task.py | 24 + squadai/cli/reset_memories_command.py | 49 + squadai/cli/run_squad.py | 23 + squadai/cli/templates/__init__.py | 0 squadai/cli/templates/pipeline/.gitignore | 2 + squadai/cli/templates/pipeline/README.md | 57 ++ squadai/cli/templates/pipeline/__init__.py | 0 .../crews/research_crew/config/agents.yaml | 19 + .../crews/research_crew/config/tasks.yaml | 16 + .../crews/research_crew/research_crew.py | 58 ++ .../write_linkedin_crew/config/agents.yaml | 0 .../write_linkedin_crew/config/tasks.yaml | 0 .../write_linkedin_crew.py | 51 + .../crews/write_x_crew/config/agents.yaml | 14 + .../crews/write_x_crew/config/tasks.yaml | 22 + .../crews/write_x_crew/write_x_crew.py | 36 + squadai/cli/templates/pipeline/main.py | 26 + .../templates/pipeline/pipelines/__init__.py | 0 .../templates/pipeline/pipelines/pipeline.py | 87 ++ squadai/cli/templates/pipeline/pyproject.toml | 17 + .../cli/templates/pipeline/tools/__init__.py | 0 .../templates/pipeline/tools/custom_tool.py | 12 + .../cli/templates/pipeline_router/.gitignore | 2 + .../cli/templates/pipeline_router/README.md | 54 + .../cli/templates/pipeline_router/__init__.py | 0 .../pipeline_router/config/agents.yaml | 19 + .../pipeline_router/config/tasks.yaml | 17 + squadai/cli/templates/pipeline_router/main.py | 75 ++ .../pipeline_router/pipelines/__init__.py | 0 .../pipelines/pipeline_classifier.py | 24 + .../pipelines/pipeline_normal.py | 24 + .../pipelines/pipeline_urgent.py | 23 + .../templates/pipeline_router/pyproject.toml | 20 + .../classifier_squad/classifier_squad.py | 40 + .../classifier_squad/config/agents.yaml | 7 + .../squads/classifier_squad/config/tasks.yaml | 7 + .../squads/normal_squad/config/agents.yaml | 7 + .../squads/normal_squad/config/tasks.yaml | 6 + .../squads/normal_squad/normal_squad.py | 36 + .../squads/urgent_squad/config/agents.yaml | 7 + .../squads/urgent_squad/config/tasks.yaml | 6 + .../squads/urgent_squad/urgent_squad.py | 36 + .../pipeline_router/tools/__init__.py | 0 .../pipeline_router/tools/custom_tool.py | 12 + squadai/cli/templates/squad/.gitignore | 2 + squadai/cli/templates/squad/README.md | 54 + squadai/cli/templates/squad/__init__.py | 0 .../cli/templates/squad/config/agents.yaml | 19 + squadai/cli/templates/squad/config/tasks.yaml | 17 + squadai/cli/templates/squad/main.py | 54 + squadai/cli/templates/squad/pyproject.toml | 21 + squadai/cli/templates/squad/squad.py | 53 + squadai/cli/templates/squad/tools/__init__.py | 0 .../cli/templates/squad/tools/custom_tool.py | 12 + squadai/cli/train_squad.py | 32 + squadai/cli/utils.py | 18 + squadai/memory/__init__.py | 5 + squadai/memory/contextual/__init__.py | 0 .../memory/contextual/contextual_memory.py | 65 ++ squadai/memory/entity/__init__.py | 0 squadai/memory/entity/entity_memory.py | 31 + squadai/memory/entity/entity_memory_item.py | 12 + squadai/memory/long_term/__init__.py | 0 squadai/memory/long_term/long_term_memory.py | 35 + .../memory/long_term/long_term_memory_item.py | 19 + squadai/memory/memory.py | 27 + squadai/memory/short_term/__init__.py | 0 .../memory/short_term/short_term_memory.py | 41 + .../short_term/short_term_memory_item.py | 13 + squadai/memory/storage/interface.py | 14 + .../storage/kickoff_task_outputs_storage.py | 166 +++ squadai/memory/storage/ltm_sqlite_storage.py | 122 +++ squadai/memory/storage/rag_storage.py | 119 +++ squadai/pipeline/__init__.py | 5 + squadai/pipeline/pipeline.py | 405 ++++++++ squadai/pipeline/pipeline_kickoff_result.py | 61 ++ squadai/pipeline/pipeline_output.py | 20 + squadai/process.py | 11 + squadai/project/__init__.py | 29 + squadai/project/annotations.py | 124 +++ squadai/project/pipeline_base.py | 58 ++ squadai/project/squad_base.py | 178 ++++ squadai/project/utils.py | 11 + squadai/routers/__init__.py | 3 + squadai/routers/router.py | 84 ++ squadai/squad.py | 943 ++++++++++++++++++ squadai/squadai_tools/__init__.py | 44 + .../adapters/embedchain_adapter.py | 25 + .../squadai_tools/adapters/lancedb_adapter.py | 56 ++ .../adapters/pdf_embedchain_adapter.py | 32 + squadai/squadai_tools/tools/__init__.py | 52 + squadai/squadai_tools/tools/base_tool.py | 151 +++ .../tools/browserbase_load_tool/README.md | 38 + .../browserbase_load_tool.py | 44 + .../tools/code_docs_search_tool/README.md | 56 ++ .../code_docs_search_tool.py | 60 ++ .../tools/code_interpreter_tool/Dockerfile | 14 + .../tools/code_interpreter_tool/README.md | 29 + .../code_interpreter_tool.py | 94 ++ .../tools/composio_tool/README.md | 72 ++ .../tools/composio_tool/composio_tool.py | 122 +++ .../tools/csv_search_tool/README.md | 59 ++ .../tools/csv_search_tool/csv_search_tool.py | 60 ++ .../squadai_tools/tools/dalle_tool/README.MD | 41 + .../tools/dalle_tool/dalle_tool.py | 48 + .../tools/directory_read_tool/README.md | 40 + .../directory_read_tool.py | 38 + .../tools/directory_search_tool/README.md | 55 + .../directory_search_tool.py | 60 ++ .../tools/docx_search_tool/README.md | 57 ++ .../docx_search_tool/docx_search_tool.py | 66 ++ .../squadai_tools/tools/exa_tools/README.md | 30 + .../tools/exa_tools/exa_base_tool.py | 36 + .../tools/exa_tools/exa_search_tool.py | 28 + .../tools/file_read_tool/README.md | 29 + .../tools/file_read_tool/file_read_tool.py | 46 + .../tools/file_writer_tool/README.md | 35 + .../file_writer_tool/file_writer_tool.py | 39 + .../firecrawl_crawl_website_tool/README.md | 42 + .../firecrawl_crawl_website_tool.py | 38 + .../firecrawl_scrape_website_tool/README.md | 38 + .../firecrawl_scrape_website_tool.py | 42 + .../tools/firecrawl_search_tool/README.md | 35 + .../firecrawl_search_tool.py | 38 + .../tools/github_search_tool/README.md | 67 ++ .../github_search_tool/github_search_tool.py | 71 ++ .../tools/json_search_tool/README.md | 55 + .../json_search_tool/json_search_tool.py | 60 ++ .../tools/llamaindex_tool/README.md | 53 + .../tools/llamaindex_tool/llamaindex_tool.py | 84 ++ .../tools/mdx_seach_tool/README.md | 57 ++ .../tools/mdx_seach_tool/mdx_search_tool.py | 60 ++ .../tools/multion_tool/README.md | 54 + .../tools/multion_tool/example.py | 29 + .../tools/multion_tool/multion_tool.py | 65 ++ .../tools/mysql_search_tool/README.md | 56 ++ .../mysql_search_tool/mysql_search_tool.py | 44 + squadai/squadai_tools/tools/nl2sql/README.md | 74 ++ .../tools/nl2sql/images/image-2.png | Bin 0 -> 84676 bytes .../tools/nl2sql/images/image-3.png | Bin 0 -> 83521 bytes .../tools/nl2sql/images/image-4.png | Bin 0 -> 84400 bytes .../tools/nl2sql/images/image-5.png | Bin 0 -> 66131 bytes .../tools/nl2sql/images/image-7.png | Bin 0 -> 24641 bytes .../tools/nl2sql/images/image-9.png | Bin 0 -> 56650 bytes .../squadai_tools/tools/nl2sql/nl2sql_tool.py | 80 ++ .../tools/pdf_search_tool/README.md | 57 ++ .../tools/pdf_search_tool/pdf_search_tool.py | 69 ++ .../pdf_text_writing_tool.py | 66 ++ .../tools/pg_seach_tool/README.md | 56 ++ .../tools/pg_seach_tool/pg_search_tool.py | 44 + squadai/squadai_tools/tools/rag/README.md | 61 ++ squadai/squadai_tools/tools/rag/__init__.py | 0 squadai/squadai_tools/tools/rag/rag_tool.py | 71 ++ .../scrape_element_from_website.py | 57 ++ .../tools/scrape_website_tool/README.md | 24 + .../scrape_website_tool.py | 59 ++ .../scrapfly_scrape_website_tool/README.md | 57 ++ .../scrapfly_scrape_website_tool.py | 47 + .../tools/selenium_scraping_tool/README.md | 33 + .../selenium_scraping_tool.py | 77 ++ .../tools/serper_dev_tool/README.md | 30 + .../tools/serper_dev_tool/serper_dev_tool.py | 80 ++ .../tools/serply_api_tool/README.md | 117 +++ .../serply_api_tool/serply_job_search_tool.py | 75 ++ .../serply_news_search_tool.py | 81 ++ .../serply_scholar_search_tool.py | 86 ++ .../serply_api_tool/serply_web_search_tool.py | 93 ++ .../serply_webpage_to_markdown_tool.py | 48 + .../squadai_tools/tools/spider_tool/README.md | 81 ++ .../tools/spider_tool/spider_tool.py | 59 ++ .../tools/txt_search_tool/README.md | 59 ++ .../tools/txt_search_tool/txt_search_tool.py | 60 ++ .../squadai_tools/tools/vision_tool/README.md | 30 + .../tools/vision_tool/vision_tool.py | 93 ++ .../tools/website_search/README.md | 57 ++ .../website_search/website_search_tool.py | 60 ++ .../tools/xml_search_tool/README.md | 57 ++ .../tools/xml_search_tool/xml_search_tool.py | 60 ++ .../youtube_channel_search_tool/README.md | 57 ++ .../youtube_channel_search_tool.py | 63 ++ .../tools/youtube_video_search_tool/README.md | 60 ++ .../youtube_video_search_tool.py | 60 ++ squadai/squads/__init__.py | 3 + squadai/squads/squad_output.py | 49 + squadai/task.py | 394 ++++++++ squadai/tasks/__init__.py | 4 + squadai/tasks/conditional_task.py | 47 + squadai/tasks/output_format.py | 9 + squadai/tasks/task_output.py | 64 ++ squadai/tools/__init__.py | 0 squadai/tools/agent_tools.py | 25 + squadai/tools/cache_tools.py | 27 + squadai/tools/tool_calling.py | 21 + squadai/tools/tool_output_parser.py | 39 + squadai/tools/tool_usage.py | 405 ++++++++ squadai/translations/en.json | 35 + squadai/types/__init__.py | 0 squadai/types/usage_metrics.py | 36 + squadai/utilities/__init__.py | 26 + squadai/utilities/config.py | 40 + squadai/utilities/constants.py | 2 + squadai/utilities/converter.py | 229 +++++ .../evaluators/squad_evaluator_handler.py | 163 +++ .../utilities/evaluators/task_evaluator.py | 133 +++ .../context_window_exceeding_exception.py | 26 + squadai/utilities/file_handler.py | 70 ++ squadai/utilities/formatter.py | 20 + squadai/utilities/i18n.py | 51 + squadai/utilities/instructor.py | 50 + squadai/utilities/logger.py | 17 + squadai/utilities/parser.py | 31 + squadai/utilities/paths.py | 27 + squadai/utilities/planning_handler.py | 90 ++ squadai/utilities/printer.py | 34 + squadai/utilities/prompts.py | 64 ++ squadai/utilities/pydantic_schema_parser.py | 42 + squadai/utilities/rpm_controller.py | 73 ++ squadai/utilities/squad_json_encoder.py | 31 + .../utilities/squad_pydantic_output_parser.py | 48 + .../utilities/task_output_storage_handler.py | 61 ++ squadai/utilities/token_counter_callback.py | 36 + squadai/utilities/training_handler.py | 31 + system_message.txt | 50 + tool_reg/__init__.py | 27 + tool_reg/file_tools.py | 49 + tool_reg/info_tools.py | 14 + tool_reg/scrape_tools.py | 61 ++ tool_reg/search_tools.py | 15 + 256 files changed, 14095 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 squadai/__init__.py create mode 100644 squadai/__main__.py create mode 100644 squadai/agent.py create mode 100644 squadai/agents/__init__.py create mode 100644 squadai/agents/agent_builder/__init__.py create mode 100644 squadai/agents/agent_builder/base_agent.py create mode 100644 squadai/agents/agent_builder/base_agent_executor_mixin.py create mode 100644 squadai/agents/agent_builder/utilities/__init__.py create mode 100644 squadai/agents/agent_builder/utilities/base_agent_tool.py create mode 100644 squadai/agents/agent_builder/utilities/base_output_converter.py create mode 100644 squadai/agents/agent_builder/utilities/base_token_process.py create mode 100644 squadai/agents/cache/__init__.py create mode 100644 squadai/agents/cache/cache_handler.py create mode 100644 squadai/agents/executor.py create mode 100644 squadai/agents/parser.py create mode 100644 squadai/agents/tools_handler.py create mode 100644 squadai/cli/__init__.py create mode 100644 squadai/cli/cli.py create mode 100644 squadai/cli/create_pipeline.py create mode 100644 squadai/cli/create_squad.py create mode 100644 squadai/cli/evaluate_squad.py create mode 100644 squadai/cli/install_squad.py create mode 100644 squadai/cli/replay_from_task.py create mode 100644 squadai/cli/reset_memories_command.py create mode 100644 squadai/cli/run_squad.py create mode 100644 squadai/cli/templates/__init__.py create mode 100644 squadai/cli/templates/pipeline/.gitignore create mode 100644 squadai/cli/templates/pipeline/README.md create mode 100644 squadai/cli/templates/pipeline/__init__.py create mode 100644 squadai/cli/templates/pipeline/crews/research_crew/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline/crews/research_crew/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline/crews/research_crew/research_crew.py create mode 100644 squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline/crews/write_linkedin_crew/write_linkedin_crew.py create mode 100644 squadai/cli/templates/pipeline/crews/write_x_crew/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline/crews/write_x_crew/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline/crews/write_x_crew/write_x_crew.py create mode 100644 squadai/cli/templates/pipeline/main.py create mode 100644 squadai/cli/templates/pipeline/pipelines/__init__.py create mode 100644 squadai/cli/templates/pipeline/pipelines/pipeline.py create mode 100644 squadai/cli/templates/pipeline/pyproject.toml create mode 100644 squadai/cli/templates/pipeline/tools/__init__.py create mode 100644 squadai/cli/templates/pipeline/tools/custom_tool.py create mode 100644 squadai/cli/templates/pipeline_router/.gitignore create mode 100644 squadai/cli/templates/pipeline_router/README.md create mode 100644 squadai/cli/templates/pipeline_router/__init__.py create mode 100644 squadai/cli/templates/pipeline_router/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline_router/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline_router/main.py create mode 100644 squadai/cli/templates/pipeline_router/pipelines/__init__.py create mode 100644 squadai/cli/templates/pipeline_router/pipelines/pipeline_classifier.py create mode 100644 squadai/cli/templates/pipeline_router/pipelines/pipeline_normal.py create mode 100644 squadai/cli/templates/pipeline_router/pipelines/pipeline_urgent.py create mode 100644 squadai/cli/templates/pipeline_router/pyproject.toml create mode 100644 squadai/cli/templates/pipeline_router/squads/classifier_squad/classifier_squad.py create mode 100644 squadai/cli/templates/pipeline_router/squads/classifier_squad/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/classifier_squad/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/normal_squad/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/normal_squad/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/normal_squad/normal_squad.py create mode 100644 squadai/cli/templates/pipeline_router/squads/urgent_squad/config/agents.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/urgent_squad/config/tasks.yaml create mode 100644 squadai/cli/templates/pipeline_router/squads/urgent_squad/urgent_squad.py create mode 100644 squadai/cli/templates/pipeline_router/tools/__init__.py create mode 100644 squadai/cli/templates/pipeline_router/tools/custom_tool.py create mode 100644 squadai/cli/templates/squad/.gitignore create mode 100644 squadai/cli/templates/squad/README.md create mode 100644 squadai/cli/templates/squad/__init__.py create mode 100644 squadai/cli/templates/squad/config/agents.yaml create mode 100644 squadai/cli/templates/squad/config/tasks.yaml create mode 100644 squadai/cli/templates/squad/main.py create mode 100644 squadai/cli/templates/squad/pyproject.toml create mode 100644 squadai/cli/templates/squad/squad.py create mode 100644 squadai/cli/templates/squad/tools/__init__.py create mode 100644 squadai/cli/templates/squad/tools/custom_tool.py create mode 100644 squadai/cli/train_squad.py create mode 100644 squadai/cli/utils.py create mode 100644 squadai/memory/__init__.py create mode 100644 squadai/memory/contextual/__init__.py create mode 100644 squadai/memory/contextual/contextual_memory.py create mode 100644 squadai/memory/entity/__init__.py create mode 100644 squadai/memory/entity/entity_memory.py create mode 100644 squadai/memory/entity/entity_memory_item.py create mode 100644 squadai/memory/long_term/__init__.py create mode 100644 squadai/memory/long_term/long_term_memory.py create mode 100644 squadai/memory/long_term/long_term_memory_item.py create mode 100644 squadai/memory/memory.py create mode 100644 squadai/memory/short_term/__init__.py create mode 100644 squadai/memory/short_term/short_term_memory.py create mode 100644 squadai/memory/short_term/short_term_memory_item.py create mode 100644 squadai/memory/storage/interface.py create mode 100644 squadai/memory/storage/kickoff_task_outputs_storage.py create mode 100644 squadai/memory/storage/ltm_sqlite_storage.py create mode 100644 squadai/memory/storage/rag_storage.py create mode 100644 squadai/pipeline/__init__.py create mode 100644 squadai/pipeline/pipeline.py create mode 100644 squadai/pipeline/pipeline_kickoff_result.py create mode 100644 squadai/pipeline/pipeline_output.py create mode 100644 squadai/process.py create mode 100644 squadai/project/__init__.py create mode 100644 squadai/project/annotations.py create mode 100644 squadai/project/pipeline_base.py create mode 100644 squadai/project/squad_base.py create mode 100644 squadai/project/utils.py create mode 100644 squadai/routers/__init__.py create mode 100644 squadai/routers/router.py create mode 100644 squadai/squad.py create mode 100644 squadai/squadai_tools/__init__.py create mode 100644 squadai/squadai_tools/adapters/embedchain_adapter.py create mode 100644 squadai/squadai_tools/adapters/lancedb_adapter.py create mode 100644 squadai/squadai_tools/adapters/pdf_embedchain_adapter.py create mode 100644 squadai/squadai_tools/tools/__init__.py create mode 100644 squadai/squadai_tools/tools/base_tool.py create mode 100644 squadai/squadai_tools/tools/browserbase_load_tool/README.md create mode 100644 squadai/squadai_tools/tools/browserbase_load_tool/browserbase_load_tool.py create mode 100644 squadai/squadai_tools/tools/code_docs_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/code_docs_search_tool/code_docs_search_tool.py create mode 100644 squadai/squadai_tools/tools/code_interpreter_tool/Dockerfile create mode 100644 squadai/squadai_tools/tools/code_interpreter_tool/README.md create mode 100644 squadai/squadai_tools/tools/code_interpreter_tool/code_interpreter_tool.py create mode 100644 squadai/squadai_tools/tools/composio_tool/README.md create mode 100644 squadai/squadai_tools/tools/composio_tool/composio_tool.py create mode 100644 squadai/squadai_tools/tools/csv_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/csv_search_tool/csv_search_tool.py create mode 100644 squadai/squadai_tools/tools/dalle_tool/README.MD create mode 100644 squadai/squadai_tools/tools/dalle_tool/dalle_tool.py create mode 100644 squadai/squadai_tools/tools/directory_read_tool/README.md create mode 100644 squadai/squadai_tools/tools/directory_read_tool/directory_read_tool.py create mode 100644 squadai/squadai_tools/tools/directory_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/directory_search_tool/directory_search_tool.py create mode 100644 squadai/squadai_tools/tools/docx_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/docx_search_tool/docx_search_tool.py create mode 100644 squadai/squadai_tools/tools/exa_tools/README.md create mode 100644 squadai/squadai_tools/tools/exa_tools/exa_base_tool.py create mode 100644 squadai/squadai_tools/tools/exa_tools/exa_search_tool.py create mode 100644 squadai/squadai_tools/tools/file_read_tool/README.md create mode 100644 squadai/squadai_tools/tools/file_read_tool/file_read_tool.py create mode 100644 squadai/squadai_tools/tools/file_writer_tool/README.md create mode 100644 squadai/squadai_tools/tools/file_writer_tool/file_writer_tool.py create mode 100644 squadai/squadai_tools/tools/firecrawl_crawl_website_tool/README.md create mode 100644 squadai/squadai_tools/tools/firecrawl_crawl_website_tool/firecrawl_crawl_website_tool.py create mode 100644 squadai/squadai_tools/tools/firecrawl_scrape_website_tool/README.md create mode 100644 squadai/squadai_tools/tools/firecrawl_scrape_website_tool/firecrawl_scrape_website_tool.py create mode 100644 squadai/squadai_tools/tools/firecrawl_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/firecrawl_search_tool/firecrawl_search_tool.py create mode 100644 squadai/squadai_tools/tools/github_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/github_search_tool/github_search_tool.py create mode 100644 squadai/squadai_tools/tools/json_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/json_search_tool/json_search_tool.py create mode 100644 squadai/squadai_tools/tools/llamaindex_tool/README.md create mode 100644 squadai/squadai_tools/tools/llamaindex_tool/llamaindex_tool.py create mode 100644 squadai/squadai_tools/tools/mdx_seach_tool/README.md create mode 100644 squadai/squadai_tools/tools/mdx_seach_tool/mdx_search_tool.py create mode 100644 squadai/squadai_tools/tools/multion_tool/README.md create mode 100644 squadai/squadai_tools/tools/multion_tool/example.py create mode 100644 squadai/squadai_tools/tools/multion_tool/multion_tool.py create mode 100644 squadai/squadai_tools/tools/mysql_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/mysql_search_tool/mysql_search_tool.py create mode 100644 squadai/squadai_tools/tools/nl2sql/README.md create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-2.png create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-3.png create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-4.png create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-5.png create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-7.png create mode 100644 squadai/squadai_tools/tools/nl2sql/images/image-9.png create mode 100644 squadai/squadai_tools/tools/nl2sql/nl2sql_tool.py create mode 100644 squadai/squadai_tools/tools/pdf_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/pdf_search_tool/pdf_search_tool.py create mode 100644 squadai/squadai_tools/tools/pdf_text_writing_tool/pdf_text_writing_tool.py create mode 100644 squadai/squadai_tools/tools/pg_seach_tool/README.md create mode 100644 squadai/squadai_tools/tools/pg_seach_tool/pg_search_tool.py create mode 100644 squadai/squadai_tools/tools/rag/README.md create mode 100644 squadai/squadai_tools/tools/rag/__init__.py create mode 100644 squadai/squadai_tools/tools/rag/rag_tool.py create mode 100644 squadai/squadai_tools/tools/scrape_element_from_website/scrape_element_from_website.py create mode 100644 squadai/squadai_tools/tools/scrape_website_tool/README.md create mode 100644 squadai/squadai_tools/tools/scrape_website_tool/scrape_website_tool.py create mode 100644 squadai/squadai_tools/tools/scrapfly_scrape_website_tool/README.md create mode 100644 squadai/squadai_tools/tools/scrapfly_scrape_website_tool/scrapfly_scrape_website_tool.py create mode 100644 squadai/squadai_tools/tools/selenium_scraping_tool/README.md create mode 100644 squadai/squadai_tools/tools/selenium_scraping_tool/selenium_scraping_tool.py create mode 100644 squadai/squadai_tools/tools/serper_dev_tool/README.md create mode 100644 squadai/squadai_tools/tools/serper_dev_tool/serper_dev_tool.py create mode 100644 squadai/squadai_tools/tools/serply_api_tool/README.md create mode 100644 squadai/squadai_tools/tools/serply_api_tool/serply_job_search_tool.py create mode 100644 squadai/squadai_tools/tools/serply_api_tool/serply_news_search_tool.py create mode 100644 squadai/squadai_tools/tools/serply_api_tool/serply_scholar_search_tool.py create mode 100644 squadai/squadai_tools/tools/serply_api_tool/serply_web_search_tool.py create mode 100644 squadai/squadai_tools/tools/serply_api_tool/serply_webpage_to_markdown_tool.py create mode 100644 squadai/squadai_tools/tools/spider_tool/README.md create mode 100644 squadai/squadai_tools/tools/spider_tool/spider_tool.py create mode 100644 squadai/squadai_tools/tools/txt_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/txt_search_tool/txt_search_tool.py create mode 100644 squadai/squadai_tools/tools/vision_tool/README.md create mode 100644 squadai/squadai_tools/tools/vision_tool/vision_tool.py create mode 100644 squadai/squadai_tools/tools/website_search/README.md create mode 100644 squadai/squadai_tools/tools/website_search/website_search_tool.py create mode 100644 squadai/squadai_tools/tools/xml_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/xml_search_tool/xml_search_tool.py create mode 100644 squadai/squadai_tools/tools/youtube_channel_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/youtube_channel_search_tool/youtube_channel_search_tool.py create mode 100644 squadai/squadai_tools/tools/youtube_video_search_tool/README.md create mode 100644 squadai/squadai_tools/tools/youtube_video_search_tool/youtube_video_search_tool.py create mode 100644 squadai/squads/__init__.py create mode 100644 squadai/squads/squad_output.py create mode 100644 squadai/task.py create mode 100644 squadai/tasks/__init__.py create mode 100644 squadai/tasks/conditional_task.py create mode 100644 squadai/tasks/output_format.py create mode 100644 squadai/tasks/task_output.py create mode 100644 squadai/tools/__init__.py create mode 100644 squadai/tools/agent_tools.py create mode 100644 squadai/tools/cache_tools.py create mode 100644 squadai/tools/tool_calling.py create mode 100644 squadai/tools/tool_output_parser.py create mode 100644 squadai/tools/tool_usage.py create mode 100644 squadai/translations/en.json create mode 100644 squadai/types/__init__.py create mode 100644 squadai/types/usage_metrics.py create mode 100644 squadai/utilities/__init__.py create mode 100644 squadai/utilities/config.py create mode 100644 squadai/utilities/constants.py create mode 100644 squadai/utilities/converter.py create mode 100644 squadai/utilities/evaluators/squad_evaluator_handler.py create mode 100644 squadai/utilities/evaluators/task_evaluator.py create mode 100644 squadai/utilities/exceptions/context_window_exceeding_exception.py create mode 100644 squadai/utilities/file_handler.py create mode 100644 squadai/utilities/formatter.py create mode 100644 squadai/utilities/i18n.py create mode 100644 squadai/utilities/instructor.py create mode 100644 squadai/utilities/logger.py create mode 100644 squadai/utilities/parser.py create mode 100644 squadai/utilities/paths.py create mode 100644 squadai/utilities/planning_handler.py create mode 100644 squadai/utilities/printer.py create mode 100644 squadai/utilities/prompts.py create mode 100644 squadai/utilities/pydantic_schema_parser.py create mode 100644 squadai/utilities/rpm_controller.py create mode 100644 squadai/utilities/squad_json_encoder.py create mode 100644 squadai/utilities/squad_pydantic_output_parser.py create mode 100644 squadai/utilities/task_output_storage_handler.py create mode 100644 squadai/utilities/token_counter_callback.py create mode 100644 squadai/utilities/training_handler.py create mode 100644 system_message.txt create mode 100644 tool_reg/__init__.py create mode 100644 tool_reg/file_tools.py create mode 100644 tool_reg/info_tools.py create mode 100644 tool_reg/scrape_tools.py create mode 100644 tool_reg/search_tools.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..fa3d2b7 --- /dev/null +++ b/.env.template @@ -0,0 +1,11 @@ +GROQ_API_KEY= + +SERPAPI_API_KEY= + +SQUADAI_STORAGE_DIR=Storage + +GROQ_MODEL_NAME=llama-3.1-70b-versatile + +WOLFRAM_ALPHA_APPID= + +FIRECRAWL_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5e751e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ + +.env + +Workspace/ + +venv/ + +find_os.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f065d25 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Spyder Rex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1eb92a --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# SquadAI + +SquadAI is a an autonomous agent program based on CrewAI, but it is intended to be used as a standalone program like AutoGPT rather than a package. It uses the open source Llama3 model from Groq API rather than OpenAI's models. + +## Features +- **Llama3 Model Integration**: Utilizes the Llama3 model via Groq API, providing a free alternative to other AI models. +- **Lightweight Design**: Built to be simple and easy to understand, making it accessible for developers at any level. +- **Open-Source Focus**: Aiming to attract contributors to help develop and enhance the project. +- **Access to LangChain tools + +## Getting Started + +### Prerequisites +Ensure you have Python installed on your system. You can check by running: +```bash +python --version +``` +or +```bash +python3 --version +``` + +You will also need to go to Groq Cloud and get a Groq API key. Chage the name of env.template to .env and add your API key. Do the same thing for the WolframAlpha API. Also, consider getting a SerpApi API key as well and add it to the .env file. As this project grows more API keys will probably be needed, but I intend to keep everything free and open source. And you will need to get a Firecrawl api key for the webscraper tool. + +### Installation +1. Clone the Repository: +```bash +git clone https://github.com/SpyderRex/SquadAI.git +cd SquadAI +``` + +2. Install the Requirements: +Install the necessary dependencies using pip: +```bash +pip install -r requirements.txt +``` + +## Usage +To run SquadAI, simply execute the following command in your terminal: +```bash +python3 main.py +``` +A prompt will appear asking the user to provide a goal. The process toward completing that goal will be executed. + +Alternately, you can create a project in the same way that crewAI does: +```bash +python3 -m squadai create squad test_squad +``` + +## squadai_tools +The original crewAI program also has a separate package called crewai-tools that must be installed separately. However, I have added this functionality within the project itself, in a module called squadai_tools. This is separate from the tool_reg directory that initializes the LangChain tools for the agents. + +## Contributing +Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. + +Obviously this is a work in project and an experiment with autonomous agent programs using free, open source models. More tools and functionality will be added as the project grows. + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License +Distributed under the MIT License. See `LICENSE.txt` for more information. + +## Contact +Spyder Rex - rex.multimedia.llc@gmail.com + +Project Link: https://github.com/SpyderRex/SquadAI + +## Donating +If you wish to donate financially to this project, you can do so [here](https://www.paypal.com/donate/?hosted_button_id=N8HR4SN2J6FPG) diff --git a/main.py b/main.py new file mode 100644 index 0000000..32ac582 --- /dev/null +++ b/main.py @@ -0,0 +1,207 @@ +import os +import json +from typing import List, Dict, Any +from squadai import Agent, Task, Squad, Process +from squadai.squadai_tools import FileWriterTool +from langchain_community.tools import DuckDuckGoSearchRun +from langchain_community.tools import WikipediaQueryRun +from langchain_community.utilities import WikipediaAPIWrapper +from groq import Groq +from dotenv import load_dotenv +from tool_reg import tool_registry +import tool_reg.search_tools +import tool_reg.file_tools +import tool_reg.info_tools +from tool_reg.scrape_tools import BrowserTools + +load_dotenv() + +# Initialize tools +duckduckgo_tool = tool_registry.get("duckduckgo") +wikipedia_tool = tool_registry.get("wikipedia") +wolframalpha_tool = tool_registry.get("wolframalpha") +write_file_tool = tool_registry.get("write_file") +read_file_tool = tool_registry.get("read_file") +list_directory_tool = tool_registry.get("list_directory") +copy_file_tool = tool_registry.get("copy_file") +delete_file_tool = tool_registry.get("delete_file") +file_search_tool = tool_registry.get("file_search") +move_file_tool = tool_registry.get("move_file") +scrape_tool = BrowserTools.scrape_and_summarize_website + +# Set up Groq API (make sure to set your API key in the environment variables) +groq_api_key = os.getenv("GROQ_API_KEY") +client = Groq(api_key=groq_api_key) + +def get_squad_config(user_prompt: str) -> Dict[str, Any]: + """ + Use Groq's API to generate a SquadAI configuration based on the user's prompt. + """ + with open("system_message.txt", "r") as f: + system_message = f.read() + + response = client.chat.completions.create( + model="llama-3.1-70b-versatile", + messages=[ + {"role": "system", "content": system_message}, + {"role": "user", "content": f"Create a SquadAI configuration for the following goal: {user_prompt}"} + ] + ) + + llm_response = response.choices[0].message.content + print("Raw LLM response:", llm_response) + + # Remove backticks if present + llm_response = llm_response.strip('`') + if llm_response.startswith('json'): + llm_response = llm_response[4:].strip() + + try: + config = json.loads(llm_response) + except json.JSONDecodeError: + print("Error: Invalid JSON. Attempting to fix...") + config = fix_json(llm_response) + + return config + +def fix_json(invalid_json: str) -> Dict[str, Any]: + """ + Attempt to fix invalid JSON by sending it back to the LLM for correction. + """ + system_message = """ + The following JSON is invalid. Please correct any syntax errors and return a valid JSON object. + Only respond with the corrected JSON, nothing else. + """ + + response = client.chat.completions.create( + model="llama-3.1-70b-versatile", + messages=[ + {"role": "system", "content": system_message}, + {"role": "user", "content": invalid_json} + ] + ) + + corrected_json = response.choices[0].message.content + corrected_json = corrected_json.strip('`') + if corrected_json.startswith('json'): + corrected_json = corrected_json[4:].strip() + + try: + return json.loads(corrected_json) + except json.JSONDecodeError: + raise ValueError("Unable to generate valid JSON configuration. Please try again with a different prompt.") + +def create_agent(agent_config: Dict[str, Any]) -> Agent: + """ + Create an Agent instance from a configuration dictionary. + """ + tools = [] + if "duckduckgo_tool" in agent_config["tools"]: + tools.append(duckduckgo_tool) + if "wikipedia_tool" in agent_config["tools"]: + tools.append(wikipedia_tool) + if "wolframalpha_tool" in agent_config["tools"]: + tools.append(wolframalpha_tool) + if "write_file_tool" in agent_config["tools"]: + tools.append(write_file_tool) + if "read_file_tool" in agent_config["tools"]: + tools.append(read_file_tool) + if "list_directory_tool" in agent_config["tools"]: + tools.append(list_directory_tool) + if "copy_file_tool" in agent_config["tools"]: + tools.append(copy_file_tool) + if "delete_file_tool" in agent_config["tools"]: + tools.append(delete_file_tool) + if "file_search_tool" in agent_config["tools"]: + tools.append(file_search_tool) + if "move_file_tool" in agent_config["tools"]: + tools.append(move_file_tool) + if "scrape_tool" in agent_config["tools"]: + tools.append(scrape_tool) + + return Agent( + role=agent_config["role"], + goal=agent_config["goal"], + backstory=agent_config["backstory"], + verbose=agent_config["verbose"], + allow_delegation=agent_config["allow_delegation"], + tools=tools + ) + +def create_task(task_config: Dict[str,Any], agents: List[Agent]) -> Task: + """ + Create a Task instance from a configuration dictionary and a list of available agents. + """ + agent = next(agent for agent in agents if agent.role == task_config["agent"]) + return Task( + description=task_config["description"], + expected_output=task_config["expected_output"], + agent=agent + ) + +def create_squad(squad_config: Dict[str, Any], agents: List[Agent], tasks: List[Task]) -> Squad: + """ + Create a Squad instance from configuration dictionary, a list of available agents, and a list of tasks. + """ + squad_agents = [next(agent for agent in agents if agent.role == role) for role in squad_config["agents"]] + squad_tasks = [next(task for task in tasks if task.description == desc) for desc in squad_config["tasks"]] + + manager = None + if squad_config["process"] == "hierarchical": + manager = Agent( + role="Project Manager", + goal="Efficiently manage the squad and ensure high-quality task completion", + backstory="You're an experienced project manager, skilled in overseeing complex projects and guiding teams to success. Your role is to coordinate the efforts of the squad members, ensuring that each task is completed on time and to the highest standard.", + allow_delegation=True, + verbose=True + ) + + return Squad( + name=squad_config["name"], + agents=squad_agents, + tasks=squad_tasks, + process=Process.sequential if squad_config["process"] == "sequential" else Process.hierarchical, + memory=True, + embedder={ + "provider": "cohere", + "config": { + "model": "embed-english-v3.0", "vector_dimension": 1024 + } + }, + verbose=squad_config["verbose"], + manager_agent=manager + ) + +def run_squad(config: Dict[str, Any], user_prompt: str) -> str: + """ + Run the squad based on the configuration. + """ + agents = [create_agent(agent_config) for agent_config in config["agents"]] + tasks = [create_task(task_config, agents) for task_config in config["tasks"]] + squads = [create_squad(squad_config, agents, tasks) for squad_config in config["squads"]] + + result = "Running squads:\n" + for squad in squads: + squad_result = squad.kickoff() + result += f"\n{squad.name}: {squad_result}" + return result + +def run_dynamic_squad(user_prompt: str) -> str: + """ + Run a dynamically created squadAI based on the user's prompt. + """ + config = get_squad_config(user_prompt) + return run_squad(config, user_prompt) + +def main(): + user_prompt = input("Enter your goal for squadAI: ") + try: + result = run_dynamic_squad(user_prompt) + + except Exception as e: + print(f"An error occurred: {str(e)}") + print("Please try again with a different prompt or check your configuration.") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f0cbff6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,200 @@ +aiohappyeyeballs==2.4.0 +aiohttp==3.10.5 +aiosignal==1.3.1 +alembic==1.13.2 +annotated-types==0.7.0 +anyio==4.4.0 +appdirs==1.4.4 +asgiref==3.8.1 +async-timeout==4.0.3 +attrs==24.2.0 +auth0-python==4.7.1 +backoff==2.2.1 +backports.tarfile==1.2.0 +bcrypt==4.2.0 +beautifulsoup4==4.12.3 +boto3==1.35.5 +botocore==1.35.5 +build==1.2.1 +cachetools==5.5.0 +certifi==2024.7.4 +cffi==1.17.0 +charset-normalizer==3.3.2 +chroma-hnswlib==0.7.3 +chromadb==0.4.24 +click==8.1.7 +cohere==5.8.1 +coloredlogs==15.0.1 +cryptography==42.0.8 +dataclasses-json==0.6.7 +Deprecated==1.2.14 +distro==1.9.0 +docker==7.1.0 +docstring_parser==0.16 +duckduckgo_search==6.2.11 +embedchain==0.1.121 +exceptiongroup==1.2.2 +fastapi==0.112.2 +fastavro==1.9.5 +filelock==3.15.4 +firecrawl-py==1.2.3 +flatbuffers==24.3.25 +frozendict==2.4.4 +frozenlist==1.4.1 +fsspec==2024.6.1 +google-api-core==2.19.1 +google-auth==2.34.0 +google-cloud-aiplatform==1.63.0 +google-cloud-bigquery==3.25.0 +google-cloud-core==2.4.1 +google-cloud-resource-manager==1.12.5 +google-cloud-storage==2.18.2 +google-crc32c==1.5.0 +google-resumable-media==2.7.2 +google-search-results==2.4.2 +googleapis-common-protos==1.63.2 +gptcache==0.1.44 +greenlet==3.0.3 +groq==0.9.0 +grpc-google-iam-v1==0.13.1 +grpcio==1.66.0 +grpcio-status==1.62.3 +grpcio-tools==1.62.3 +h11==0.14.0 +h2==4.1.0 +hpack==4.0.0 +html5lib==1.1 +httpcore==1.0.5 +httptools==0.6.1 +httpx==0.27.0 +httpx-sse==0.4.0 +huggingface-hub==0.24.6 +humanfriendly==10.0 +hyperframe==6.0.1 +idna==3.8 +importlib_metadata==8.0.0 +importlib_resources==6.4.4 +instructor==1.4.0 +jaraco.context==6.0.1 +jiter==0.4.2 +jmespath==1.0.1 +json_repair==0.28.3 +jsonpatch==1.33 +jsonpointer==3.0.0 +kubernetes==30.1.0 +langchain==0.2.14 +langchain-cohere==0.1.9 +langchain-community==0.2.12 +langchain-core==0.2.37 +langchain-experimental==0.0.64 +langchain-groq==0.1.9 +langchain-ollama==0.1.3 +langchain-openai==0.1.22 +langchain-text-splitters==0.2.2 +langsmith==0.1.104 +lxml==5.3.0 +Mako==1.3.5 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +marshmallow==3.22.0 +mdurl==0.1.2 +mem0ai==0.0.20 +mmh3==4.1.0 +monotonic==1.6 +more-itertools==10.4.0 +mpmath==1.3.0 +multidict==6.0.5 +multitasking==0.0.11 +mypy-extensions==1.0.0 +numpy==1.26.4 +oauthlib==3.2.2 +ollama==0.3.2 +onnxruntime==1.19.0 +openai==1.42.0 +opentelemetry-api==1.26.0 +opentelemetry-exporter-otlp-proto-common==1.26.0 +opentelemetry-exporter-otlp-proto-grpc==1.26.0 +opentelemetry-instrumentation==0.47b0 +opentelemetry-instrumentation-asgi==0.47b0 +opentelemetry-instrumentation-fastapi==0.47b0 +opentelemetry-proto==1.26.0 +opentelemetry-sdk==1.26.0 +opentelemetry-semantic-conventions==0.47b0 +opentelemetry-util-http==0.47b0 +orjson==3.10.7 +outcome==1.3.0.post0 +overrides==7.7.0 +packaging==24.1 +pandas==2.2.2 +parameterized==0.9.0 +peewee==3.17.6 +platformdirs==4.2.2 +portalocker==2.10.1 +posthog==3.5.2 +primp==0.6.1 +proto-plus==1.24.0 +protobuf==4.25.4 +pulsar-client==3.5.0 +pyasn1==0.6.0 +pyasn1_modules==0.4.0 +pycparser==2.22 +pydantic==2.8.2 +pydantic_core==2.20.1 +pyee==11.1.0 +Pygments==2.18.0 +PyJWT==2.9.0 +pypdf==4.3.1 +PyPika==0.48.9 +pyproject_hooks==1.1.0 +pysbd==0.3.4 +PySocks==1.7.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2024.1 +PyYAML==6.0.2 +qdrant-client==1.11.0 +regex==2024.7.24 +requests==2.32.3 +requests-oauthlib==2.0.0 +rich==13.7.1 +rsa==4.9 +s3transfer==0.10.2 +schema==0.7.7 +selenium==4.23.1 +shapely==2.0.6 +shellingham==1.5.4 +six==1.16.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.6 +SQLAlchemy==2.0.32 +starlette==0.38.2 +sympy==1.13.2 +tabulate==0.9.0 +tenacity==8.5.0 +tiktoken==0.7.0 +tokenizers==0.20.0 +tomli==2.0.1 +tqdm==4.66.5 +trio==0.26.2 +trio-websocket==0.11.1 +typer==0.12.4 +types-requests==2.32.0.20240712 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +tzdata==2024.1 +urllib3==2.2.2 +uvicorn==0.30.6 +uvloop==0.20.0 +watchfiles==0.23.0 +webencodings==0.5.1 +websocket-client==1.8.0 +websockets==13.0 +wikipedia==1.4.0 +wolframalpha==5.1.3 +wrapt==1.16.0 +wsproto==1.2.0 +xmltodict==0.13.0 +yarl==1.9.4 +yfinance==0.2.43 +zipp==3.20.0 diff --git a/squadai/__init__.py b/squadai/__init__.py new file mode 100644 index 0000000..18be259 --- /dev/null +++ b/squadai/__init__.py @@ -0,0 +1,11 @@ +import warnings +warnings.filterwarnings("ignore", category=UserWarning, module="pydantic._internal._config") + +from squadai.agent import Agent +from squadai.squad import Squad +from squadai.pipeline import Pipeline +from squadai.process import Process +from squadai.task import Task + + +__all__ = ["Agent", "Squad", "Process", "Task", "Pipeline"] diff --git a/squadai/__main__.py b/squadai/__main__.py new file mode 100644 index 0000000..00dbeb5 --- /dev/null +++ b/squadai/__main__.py @@ -0,0 +1,5 @@ +# squadai/__main__.py +from squadai.cli import cli + +if __name__ == "__main__": + cli.squadai() diff --git a/squadai/agent.py b/squadai/agent.py new file mode 100644 index 0000000..2f607d3 --- /dev/null +++ b/squadai/agent.py @@ -0,0 +1,415 @@ +import os +from inspect import signature +from typing import Any, List, Optional, Tuple + +from langchain.agents.agent import RunnableAgent +from langchain.agents.tools import BaseTool +from langchain.agents.tools import tool as LangChainTool +from langchain_core.agents import AgentAction +from langchain_core.callbacks import BaseCallbackHandler +from langchain_groq import ChatGroq +from pydantic import Field, InstanceOf, PrivateAttr, model_validator +from dotenv import load_dotenv + +from squadai.agents import CacheHandler, SquadAgentExecutor, SquadAgentParser +from squadai.agents.agent_builder.base_agent import BaseAgent +from squadai.memory.contextual.contextual_memory import ContextualMemory +from squadai.tools.agent_tools import AgentTools +from squadai.utilities import Converter, Prompts +from squadai.utilities.constants import TRAINED_AGENTS_DATA_FILE, TRAINING_DATA_FILE +from squadai.utilities.token_counter_callback import TokenCalcHandler +from squadai.utilities.training_handler import SquadTrainingHandler + +load_dotenv() + +def mock_agent_ops_provider(): + def track_agent(*args, **kwargs): + def noop(f): + return f + + return noop + + return track_agent + + + +track_agent = mock_agent_ops_provider() + + +@track_agent() +class Agent(BaseAgent): + """Represents an agent in a system. + + Each agent has a role, a goal, a backstory, and an optional language model (llm). + The agent can also have memory, can operate in verbose mode, and can delegate tasks to other agents. + + Attributes: + agent_executor: An instance of the SquadAgentExecutor class. + role: The role of the agent. + goal: The objective of the agent. + backstory: The backstory of the agent. + config: Dict representation of agent configuration. + llm: The language model that will run the agent. + function_calling_llm: The language model that will handle the tool calling for this agent, it overrides the squad function_calling_llm. + max_iter: Maximum number of iterations for an agent to execute a task. + memory: Whether the agent should have memory or not. + max_rpm: Maximum number of requests per minute for the agent execution to be respected. + verbose: Whether the agent execution should be in verbose mode. + allow_delegation: Whether the agent is allowed to delegate tasks to other agents. + tools: Tools at agents disposal + step_callback: Callback to be executed after each step of the agent execution. + callbacks: A list of callback functions from the langchain library that are triggered during the agent's execution process + """ + + _times_executed: int = PrivateAttr(default=0) + max_execution_time: Optional[int] = Field( + default=None, + description="Maximum execution time for an agent to execute a task", + ) + agent_ops_agent_name: str = None # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str") + agent_ops_agent_id: str = None # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str") + cache_handler: InstanceOf[CacheHandler] = Field( + default=None, description="An instance of the CacheHandler class." + ) + step_callback: Optional[Any] = Field( + default=None, + description="Callback to be executed after each step of the agent execution.", + ) + llm: Any = Field( + default_factory=lambda: ChatGroq( + model=os.environ.get("GROQ_MODEL_NAME") + ), + description="Language model that will run the agent.", + ) + function_calling_llm: Optional[Any] = Field( + description="Language model that will run the agent.", default=None + ) + callbacks: Optional[List[InstanceOf[BaseCallbackHandler]]] = Field( + default=None, description="Callback to be executed" + ) + system_template: Optional[str] = Field( + default=None, description="System format for the agent." + ) + prompt_template: Optional[str] = Field( + default=None, description="Prompt format for the agent." + ) + response_template: Optional[str] = Field( + default=None, description="Response format for the agent." + ) + tools_results: Optional[List[Any]] = Field( + default=[], description="Results of the tools used by the agent." + ) + allow_code_execution: Optional[bool] = Field( + default=False, description="Enable code execution for the agent." + ) + max_retry_limit: int = Field( + default=2, + description="Maximum number of retries for an agent to execute a task when an error occurs.", + ) + + @model_validator(mode="after") + def post_init_setup(self): + self.agent_ops_agent_name = self.role + + # Different llms store the model name in different attributes + model_name = getattr(self.llm, "model_name", None) or getattr( + self.llm, "deployment_name", None + ) + + if model_name: + self._setup_llm_callbacks(model_name) + + if not self.agent_executor: + self._setup_agent_executor() + + return self + + def _setup_llm_callbacks(self, model_name: str): + token_handler = TokenCalcHandler(model_name, self._token_process) + + if not isinstance(self.llm.callbacks, list): + self.llm.callbacks = [] + + if not any( + isinstance(handler, TokenCalcHandler) for handler in self.llm.callbacks + ): + self.llm.callbacks.append(token_handler) + + + def _setup_agent_executor(self): + if not self.cache_handler: + self.cache_handler = CacheHandler() + self.set_cache_handler(self.cache_handler) + + def execute_task( + self, + task: Any, + context: Optional[str] = None, + tools: Optional[List[Any]] = None, + ) -> str: + """Execute a task with the agent. + + Args: + task: Task to execute. + context: Context to execute the task in. + tools: Tools to use for the task. + + Returns: + Output of the agent + """ + if self.tools_handler: + self.tools_handler.last_used_tool = {} # type: ignore # Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "ToolCalling") + + task_prompt = task.prompt() + + if context: + task_prompt = self.i18n.slice("task_with_context").format( + task=task_prompt, context=context + ) + + if self.squad and self.squad.memory: + contextual_memory = ContextualMemory( + self.squad._short_term_memory, + self.squad._long_term_memory, + self.squad._entity_memory, + ) + memory = contextual_memory.build_context_for_task(task, context) + if memory.strip() != "": + task_prompt += self.i18n.slice("memory").format(memory=memory) + + tools = tools or self.tools or [] + parsed_tools = self._parse_tools(tools) + self.create_agent_executor(tools=tools) + self.agent_executor.tools = parsed_tools + self.agent_executor.task = task + + self.agent_executor.tools_description = self._render_text_description_and_args( + parsed_tools + ) + self.agent_executor.tools_names = self.__tools_names(parsed_tools) + + if self.squad and self.squad._train: + task_prompt = self._training_handler(task_prompt=task_prompt) + else: + task_prompt = self._use_trained_data(task_prompt=task_prompt) + + try: + result = self.agent_executor.invoke( + { + "input": task_prompt, + "tool_names": self.agent_executor.tools_names, + "tools": self.agent_executor.tools_description, + } + )["output"] + except Exception as e: + self._times_executed += 1 + if self._times_executed > self.max_retry_limit: + raise e + result = self.execute_task(task, context, tools) + + if self.max_rpm and self._rpm_controller: + self._rpm_controller.stop_rpm_counter() + + # If there was any tool in self.tools_results that had result_as_answer + # set to True, return the results of the last tool that had + # result_as_answer set to True + for tool_result in self.tools_results: # type: ignore # Item "None" of "list[Any] | None" has no attribute "__iter__" (not iterable) + if tool_result.get("result_as_answer", False): + result = tool_result["result"] + + return result + + def format_log_to_str( + self, + intermediate_steps: List[Tuple[AgentAction, str]], + observation_prefix: str = "Observation: ", + llm_prefix: str = "", + ) -> str: + """Construct the scratchpad that lets the agent continue its thought process.""" + thoughts = "" + for action, observation in intermediate_steps: + thoughts += action.log + thoughts += f"\n{observation_prefix}{observation}\n{llm_prefix}" + return thoughts + + def create_agent_executor(self, tools=None) -> None: + """Create an agent executor for the agent. + + Returns: + An instance of the SquadAgentExecutor class. + """ + tools = tools or self.tools or [] + + agent_args = { + "input": lambda x: x["input"], + "tools": lambda x: x["tools"], + "tool_names": lambda x: x["tool_names"], + "agent_scratchpad": lambda x: self.format_log_to_str( + x["intermediate_steps"] + ), + } + + executor_args = { + "llm": self.llm, + "i18n": self.i18n, + "squad": self.squad, + "squad_agent": self, + "tools": self._parse_tools(tools), + "verbose": self.verbose, + "original_tools": tools, + "handle_parsing_errors": True, + "max_iterations": self.max_iter, + "max_execution_time": self.max_execution_time, + "step_callback": self.step_callback, + "tools_handler": self.tools_handler, + "function_calling_llm": self.function_calling_llm, + "callbacks": self.callbacks, + "max_tokens": self.max_tokens, + } + + if self._rpm_controller: + executor_args["request_within_rpm_limit"] = ( + self._rpm_controller.check_or_wait + ) + + prompt = Prompts( + i18n=self.i18n, + tools=tools, + system_template=self.system_template, + prompt_template=self.prompt_template, + response_template=self.response_template, + ).task_execution() + + execution_prompt = prompt.partial( + goal=self.goal, + role=self.role, + backstory=self.backstory, + ) + + stop_words = [self.i18n.slice("observation")] + + if self.response_template: + stop_words.append( + self.response_template.split("{{ .Response }}")[1].strip() + ) + + bind = self.llm.bind(stop=stop_words) + + inner_agent = agent_args | execution_prompt | bind | SquadAgentParser(agent=self) + self.agent_executor = SquadAgentExecutor( + agent=RunnableAgent(runnable=inner_agent), **executor_args + ) + + def get_delegation_tools(self, agents: List[BaseAgent]): + agent_tools = AgentTools(agents=agents) + tools = agent_tools.tools() + return tools + + def get_code_execution_tools(self): + try: + from squadai_tools import CodeInterpreterTool + + return [CodeInterpreterTool()] + except ModuleNotFoundError: + self._logger.log( + "info", "Coding tools not available. Install squadai_tools. " + ) + + def get_output_converter(self, llm, text, model, instructions): + return Converter(llm=llm, text=text, model=model, instructions=instructions) + + def _parse_tools(self, tools: List[Any]) -> List[LangChainTool]: # type: ignore # Function "langchain_core.tools.tool" is not valid as a type + """Parse tools to be used for the task.""" + tools_list = [] + try: + # tentatively try to import from squadai_tools import BaseTool as SquadAITool + from squadai_tools import BaseTool as SquadAITool + + for tool in tools: + if isinstance(tool, SquadAITool): + tools_list.append(tool.to_langchain()) + else: + tools_list.append(tool) + except ModuleNotFoundError: + tools_list = [] + for tool in tools: + tools_list.append(tool) + + return tools_list + + def _training_handler(self, task_prompt: str) -> str: + """Handle training data for the agent task prompt to improve output on Training.""" + if data := SquadTrainingHandler(TRAINING_DATA_FILE).load(): + agent_id = str(self.id) + + if data.get(agent_id): + human_feedbacks = [ + i["human_feedback"] for i in data.get(agent_id, {}).values() + ] + task_prompt += "You MUST follow these feedbacks: \n " + "\n - ".join( + human_feedbacks + ) + + return task_prompt + + def _use_trained_data(self, task_prompt: str) -> str: + """Use trained data for the agent task prompt to improve output.""" + if data := SquadTrainingHandler(TRAINED_AGENTS_DATA_FILE).load(): + if trained_data_output := data.get(self.role): + task_prompt += "You MUST follow these feedbacks: \n " + "\n - ".join( + trained_data_output["suggestions"] + ) + return task_prompt + + def _render_text_description(self, tools: List[BaseTool]) -> str: + """Render the tool name and description in plain text. + + Output will be in the format of: + + .. code-block:: markdown + + search: This tool is used for search + calculator: This tool is used for math + """ + description = "\n".join( + [ + f"Tool name: {tool.name}\nTool description:\n{tool.description}" + for tool in tools + ] + ) + + return description + + def _render_text_description_and_args(self, tools: List[BaseTool]) -> str: + """Render the tool name, description, and args in plain text. + + Output will be in the format of: + + .. code-block:: markdown + + search: This tool is used for search, args: {"query": {"type": "string"}} + calculator: This tool is used for math, \ + args: {"expression": {"type": "string"}} + """ + tool_strings = [] + for tool in tools: + args_schema = str(tool.args) + if hasattr(tool, "func") and tool.func: + sig = signature(tool.func) + description = ( + f"Tool Name: {tool.name}{sig}\nTool Description: {tool.description}" + ) + else: + description = ( + f"Tool Name: {tool.name}\nTool Description: {tool.description}" + ) + tool_strings.append(f"{description}\nTool Arguments: {args_schema}") + + return "\n".join(tool_strings) + + @staticmethod + def __tools_names(tools) -> str: + return ", ".join([t.name for t in tools]) + + def __repr__(self): + return f"Agent(role={self.role}, goal={self.goal}, backstory={self.backstory})" diff --git a/squadai/agents/__init__.py b/squadai/agents/__init__.py new file mode 100644 index 0000000..ce111b2 --- /dev/null +++ b/squadai/agents/__init__.py @@ -0,0 +1,6 @@ +from .cache.cache_handler import CacheHandler +from .executor import SquadAgentExecutor +from .parser import SquadAgentParser +from .tools_handler import ToolsHandler + +__all__ = ["CacheHandler", "SquadAgentExecutor", "SquadAgentParser", "ToolsHandler"] diff --git a/squadai/agents/agent_builder/__init__.py b/squadai/agents/agent_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/agents/agent_builder/base_agent.py b/squadai/agents/agent_builder/base_agent.py new file mode 100644 index 0000000..029e3ba --- /dev/null +++ b/squadai/agents/agent_builder/base_agent.py @@ -0,0 +1,272 @@ +import uuid +from abc import ABC, abstractmethod +from copy import copy as shallow_copy +from hashlib import md5 +from typing import Any, Dict, List, Optional, TypeVar + +from pydantic import ( + UUID4, + BaseModel, + Field, + InstanceOf, + PrivateAttr, + field_validator, + model_validator, +) +from pydantic_core import PydanticCustomError + +from squadai.agents.agent_builder.utilities.base_token_process import TokenProcess +from squadai.agents.cache.cache_handler import CacheHandler +from squadai.agents.tools_handler import ToolsHandler +from squadai.utilities import I18N, Logger, RPMController +from squadai.utilities.config import process_config + +T = TypeVar("T", bound="BaseAgent") + + +class BaseAgent(ABC, BaseModel): + """Abstract Base Class for all third party agents compatible with SquadAI. + + Attributes: + id (UUID4): Unique identifier for the agent. + role (str): Role of the agent. + goal (str): Objective of the agent. + backstory (str): Backstory of the agent. + cache (bool): Whether the agent should use a cache for tool usage. + config (Optional[Dict[str, Any]]): Configuration for the agent. + verbose (bool): Verbose mode for the Agent Execution. + max_rpm (Optional[int]): Maximum number of requests per minute for the agent execution. + allow_delegation (bool): Allow delegation of tasks to agents. + tools (Optional[List[Any]]): Tools at the agent's disposal. + max_iter (Optional[int]): Maximum iterations for an agent to execute a task. + agent_executor (InstanceOf): An instance of the SquadAgentExecutor class. + llm (Any): Language model that will run the agent. + squad (Any): Squad to which the agent belongs. + i18n (I18N): Internationalization settings. + cache_handler (InstanceOf[CacheHandler]): An instance of the CacheHandler class. + tools_handler (InstanceOf[ToolsHandler]): An instance of the ToolsHandler class. + max_tokens: Maximum number of tokens for the agent to generate in a response. + + + Methods: + execute_task(task: Any, context: Optional[str] = None, tools: Optional[List[Any]] = None) -> str: + Abstract method to execute a task. + create_agent_executor(tools=None) -> None: + Abstract method to create an agent executor. + _parse_tools(tools: List[Any]) -> List[Any]: + Abstract method to parse tools. + get_delegation_tools(agents: List["BaseAgent"]): + Abstract method to set the agents task tools for handling delegation and question asking to other agents in squad. + get_output_converter(llm, model, instructions): + Abstract method to get the converter class for the agent to create json/pydantic outputs. + interpolate_inputs(inputs: Dict[str, Any]) -> None: + Interpolate inputs into the agent description and backstory. + set_cache_handler(cache_handler: CacheHandler) -> None: + Set the cache handler for the agent. + increment_formatting_errors() -> None: + Increment formatting errors. + copy() -> "BaseAgent": + Create a copy of the agent. + set_rpm_controller(rpm_controller: RPMController) -> None: + Set the rpm controller for the agent. + set_private_attrs() -> "BaseAgent": + Set private attributes. + """ + + __hash__ = object.__hash__ # type: ignore + _logger: Logger = PrivateAttr(default_factory=lambda: Logger(verbose=False)) + _rpm_controller: Optional[RPMController] = PrivateAttr(default=None) + _request_within_rpm_limit: Any = PrivateAttr(default=None) + _original_role: Optional[str] = PrivateAttr(default=None) + _original_goal: Optional[str] = PrivateAttr(default=None) + _original_backstory: Optional[str] = PrivateAttr(default=None) + _token_process: TokenProcess = PrivateAttr(default_factory=TokenProcess) + id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True) + formatting_errors: int = Field( + default=0, description="Number of formatting errors." + ) + role: str = Field(description="Role of the agent") + goal: str = Field(description="Objective of the agent") + backstory: str = Field(description="Backstory of the agent") + config: Optional[Dict[str, Any]] = Field( + description="Configuration for the agent", default=None, exclude=True + ) + cache: bool = Field( + default=True, description="Whether the agent should use a cache for tool usage." + ) + verbose: bool = Field( + default=False, description="Verbose mode for the Agent Execution" + ) + max_rpm: Optional[int] = Field( + default=None, + description="Maximum number of requests per minute for the agent execution to be respected.", + ) + allow_delegation: bool = Field( + default=True, description="Allow delegation of tasks to agents" + ) + tools: Optional[List[Any]] = Field( + default_factory=list, description="Tools at agents' disposal" + ) + max_iter: Optional[int] = Field( + default=25, description="Maximum iterations for an agent to execute a task" + ) + agent_executor: InstanceOf = Field( + default=None, description="An instance of the SquadAgentExecutor class." + ) + llm: Any = Field( + default=None, description="Language model that will run the agent." + ) + squad: Any = Field(default=None, description="Squad to which the agent belongs.") + i18n: I18N = Field(default=I18N(), description="Internationalization settings.") + cache_handler: InstanceOf[CacheHandler] = Field( + default=None, description="An instance of the CacheHandler class." + ) + tools_handler: InstanceOf[ToolsHandler] = Field( + default=None, description="An instance of the ToolsHandler class." + ) + max_tokens: Optional[int] = Field( + default=None, description="Maximum number of tokens for the agent's execution." + ) + + @model_validator(mode="before") + @classmethod + def process_model_config(cls, values): + return process_config(values, cls) + + @model_validator(mode="after") + def validate_and_set_attributes(self): + # Validate required fields + for field in ["role", "goal", "backstory"]: + if getattr(self, field) is None: + raise ValueError( + f"{field} must be provided either directly or through config" + ) + + # Set private attributes + self._logger = Logger(verbose=self.verbose) + if self.max_rpm and not self._rpm_controller: + self._rpm_controller = RPMController( + max_rpm=self.max_rpm, logger=self._logger + ) + if not self._token_process: + self._token_process = TokenProcess() + + return self + + @field_validator("id", mode="before") + @classmethod + def _deny_user_set_id(cls, v: Optional[UUID4]) -> None: + if v: + raise PydanticCustomError( + "may_not_set_field", "This field is not to be set by the user.", {} + ) + + @model_validator(mode="after") + def set_private_attrs(self): + """Set private attributes.""" + self._logger = Logger(verbose=self.verbose) + if self.max_rpm and not self._rpm_controller: + self._rpm_controller = RPMController( + max_rpm=self.max_rpm, logger=self._logger + ) + if not self._token_process: + self._token_process = TokenProcess() + return self + + @property + def key(self): + source = [self.role, self.goal, self.backstory] + return md5("|".join(source).encode(), usedforsecurity=False).hexdigest() + + @abstractmethod + def execute_task( + self, + task: Any, + context: Optional[str] = None, + tools: Optional[List[Any]] = None, + ) -> str: + pass + + @abstractmethod + def create_agent_executor(self, tools=None) -> None: + pass + + @abstractmethod + def _parse_tools(self, tools: List[Any]) -> List[Any]: + pass + + @abstractmethod + def get_delegation_tools(self, agents: List["BaseAgent"]) -> List[Any]: + """Set the task tools that init BaseAgenTools class.""" + pass + + @abstractmethod + def get_output_converter( + self, llm: Any, text: str, model: type[BaseModel] | None, instructions: str + ): + """Get the converter class for the agent to create json/pydantic outputs.""" + pass + + def copy(self: T) -> T: # type: ignore # Signature of "copy" incompatible with supertype "BaseModel" + """Create a deep copy of the Agent.""" + exclude = { + "id", + "_logger", + "_rpm_controller", + "_request_within_rpm_limit", + "_token_process", + "agent_executor", + "tools", + "tools_handler", + "cache_handler", + "llm", + } + + # Copy llm and clear callbacks + existing_llm = shallow_copy(self.llm) + existing_llm.callbacks = [] + copied_data = self.model_dump(exclude=exclude) + copied_data = {k: v for k, v in copied_data.items() if v is not None} + + copied_agent = type(self)(**copied_data, llm=existing_llm, tools=self.tools) + + return copied_agent + + def interpolate_inputs(self, inputs: Dict[str, Any]) -> None: + """Interpolate inputs into the agent description and backstory.""" + if self._original_role is None: + self._original_role = self.role + if self._original_goal is None: + self._original_goal = self.goal + if self._original_backstory is None: + self._original_backstory = self.backstory + + if inputs: + self.role = self._original_role.format(**inputs) + self.goal = self._original_goal.format(**inputs) + self.backstory = self._original_backstory.format(**inputs) + + def set_cache_handler(self, cache_handler: CacheHandler) -> None: + """Set the cache handler for the agent. + + Args: + cache_handler: An instance of the CacheHandler class. + """ + self.tools_handler = ToolsHandler() + if self.cache: + self.cache_handler = cache_handler + self.tools_handler.cache = cache_handler + self.create_agent_executor() + + def increment_formatting_errors(self) -> None: + self.formatting_errors += 1 + + def set_rpm_controller(self, rpm_controller: RPMController) -> None: + """Set the rpm controller for the agent. + + Args: + rpm_controller: An instance of the RPMController class. + """ + if not self._rpm_controller: + self._rpm_controller = rpm_controller + self.create_agent_executor() diff --git a/squadai/agents/agent_builder/base_agent_executor_mixin.py b/squadai/agents/agent_builder/base_agent_executor_mixin.py new file mode 100644 index 0000000..982126d --- /dev/null +++ b/squadai/agents/agent_builder/base_agent_executor_mixin.py @@ -0,0 +1,107 @@ +import time +from typing import TYPE_CHECKING, Optional + +from squadai.memory.entity.entity_memory_item import EntityMemoryItem +from squadai.memory.long_term.long_term_memory_item import LongTermMemoryItem +from squadai.utilities.converter import ConverterError +from squadai.utilities.evaluators.task_evaluator import TaskEvaluator +from squadai.utilities import I18N + + +if TYPE_CHECKING: + from squadai.squad import Squad + from squadai.task import Task + from squadai.agents.agent_builder.base_agent import BaseAgent + + +class SquadAgentExecutorMixin: + squad: Optional["Squad"] + squad_agent: Optional["BaseAgent"] + task: Optional["Task"] + iterations: int + force_answer_max_iterations: int + have_forced_answer: bool + _i18n: I18N + + def _should_force_answer(self) -> bool: + """Determine if a forced answer is required based on iteration count.""" + return ( + self.iterations == self.force_answer_max_iterations + ) and not self.have_forced_answer + + def _create_short_term_memory(self, output) -> None: + """Create and save a short-term memory item if conditions are met.""" + if ( + self.squad + and self.squad_agent + and self.task + and "Action: Delegate work to coworker" not in output.log + ): + try: + if ( + hasattr(self.squad, "_short_term_memory") + and self.squad._short_term_memory + ): + self.squad._short_term_memory.save( + value=output.log, + metadata={ + "observation": self.task.description, + }, + agent=self.squad_agent.role, + ) + except Exception as e: + print(f"Failed to add to short term memory: {e}") + pass + + def _create_long_term_memory(self, output) -> None: + """Create and save long-term and entity memory items based on evaluation.""" + if ( + self.squad + and self.squad.memory + and self.squad._long_term_memory + and self.squad._entity_memory + and self.task + and self.squad_agent + ): + try: + ltm_agent = TaskEvaluator(self.squad_agent) + evaluation = ltm_agent.evaluate(self.task, output.log) + + if isinstance(evaluation, ConverterError): + return + + long_term_memory = LongTermMemoryItem( + task=self.task.description, + agent=self.squad_agent.role, + quality=evaluation.quality, + datetime=str(time.time()), + expected_output=self.task.expected_output, + metadata={ + "suggestions": evaluation.suggestions, + "quality": evaluation.quality, + }, + ) + self.squad._long_term_memory.save(long_term_memory) + + for entity in evaluation.entities: + entity_memory = EntityMemoryItem( + name=entity.name, + type=entity.type, + description=entity.description, + relationships="\n".join( + [f"- {r}" for r in entity.relationships] + ), + ) + self.squad._entity_memory.save(entity_memory) + except AttributeError as e: + print(f"Missing attributes for long term memory: {e}") + pass + except Exception as e: + print(f"Failed to add to long term memory: {e}") + pass + + def _ask_human_input(self, final_answer: dict) -> str: + """Prompt human input for final decision making.""" + return input( + self._i18n.slice("getting_input").format(final_answer=final_answer) + ) diff --git a/squadai/agents/agent_builder/utilities/__init__.py b/squadai/agents/agent_builder/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/agents/agent_builder/utilities/base_agent_tool.py b/squadai/agents/agent_builder/utilities/base_agent_tool.py new file mode 100644 index 0000000..07bd727 --- /dev/null +++ b/squadai/agents/agent_builder/utilities/base_agent_tool.py @@ -0,0 +1,86 @@ +from abc import ABC, abstractmethod +from typing import List, Optional, Union + +from pydantic import BaseModel, Field + +from squadai.agents.agent_builder.base_agent import BaseAgent +from squadai.task import Task +from squadai.utilities import I18N + + +class BaseAgentTools(BaseModel, ABC): + """Default tools around agent delegation""" + + agents: List[BaseAgent] = Field(description="List of agents in this squad.") + i18n: I18N = Field(default=I18N(), description="Internationalization settings.") + + @abstractmethod + def tools(self): + pass + + def _get_coworker(self, coworker: Optional[str], **kwargs) -> Optional[str]: + coworker = coworker or kwargs.get("co_worker") or kwargs.get("coworker") + if coworker: + is_list = coworker.startswith("[") and coworker.endswith("]") + if is_list: + coworker = coworker[1:-1].split(",")[0] + + return coworker + + def delegate_work( + self, task: str, context: str, coworker: Optional[str] = None, **kwargs + ): + """Useful to delegate a specific task to a coworker passing all necessary context and names.""" + coworker = self._get_coworker(coworker, **kwargs) + return self._execute(coworker, task, context) + + def ask_question( + self, question: str, context: str, coworker: Optional[str] = None, **kwargs + ): + """Useful to ask a question, opinion or take from a coworker passing all necessary context and names.""" + coworker = self._get_coworker(coworker, **kwargs) + return self._execute(coworker, question, context) + + def _execute( + self, agent_name: Union[str, None], task: str, context: Union[str, None] + ): + """Execute the command.""" + try: + if agent_name is None: + agent_name = "" + + # It is important to remove the quotes from the agent name. + # The reason we have to do this is because less-powerful LLM's + # have difficulty producing valid JSON. + # As a result, we end up with invalid JSON that is truncated like this: + # {"task": "....", "coworker": ".... + # when it should look like this: + # {"task": "....", "coworker": "...."} + agent_name = agent_name.casefold().replace('"', "").replace("\n", "") + + agent = [ # type: ignore # Incompatible types in assignment (expression has type "list[BaseAgent]", variable has type "str | None") + available_agent + for available_agent in self.agents + if available_agent.role.casefold().replace("\n", "") == agent_name + ] + except Exception as _: + return self.i18n.errors("agent_tool_unexsiting_coworker").format( + coworkers="\n".join( + [f"- {agent.role.casefold()}" for agent in self.agents] + ) + ) + + if not agent: + return self.i18n.errors("agent_tool_unexsiting_coworker").format( + coworkers="\n".join( + [f"- {agent.role.casefold()}" for agent in self.agents] + ) + ) + + agent = agent[0] + task_with_assigned_agent = Task( # type: ignore # Incompatible types in assignment (expression has type "Task", variable has type "str") + description=task, + agent=agent, + expected_output="Your best answer to your coworker asking you this, accounting for the context shared.", + ) + return agent.execute_task(task_with_assigned_agent, context) diff --git a/squadai/agents/agent_builder/utilities/base_output_converter.py b/squadai/agents/agent_builder/utilities/base_output_converter.py new file mode 100644 index 0000000..9b6bada --- /dev/null +++ b/squadai/agents/agent_builder/utilities/base_output_converter.py @@ -0,0 +1,47 @@ +from abc import ABC, abstractmethod +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class OutputConverter(BaseModel, ABC): + """ + Abstract base class for converting task results into structured formats. + + This class provides a framework for converting unstructured text into + either Pydantic models or JSON, tailored for specific agent requirements. + It uses a language model to interpret and structure the input text based + on given instructions. + + Attributes: + text (str): The input text to be converted. + llm (Any): The language model used for conversion. + model (Any): The target model for structuring the output. + instructions (str): Specific instructions for the conversion process. + max_attempts (int): Maximum number of conversion attempts (default: 3). + """ + + text: str = Field(description="Text to be converted.") + llm: Any = Field(description="The language model to be used to convert the text.") + model: Any = Field(description="The model to be used to convert the text.") + instructions: str = Field(description="Conversion instructions to the LLM.") + max_attempts: Optional[int] = Field( + description="Max number of attempts to try to get the output formatted.", + default=3, + ) + + @abstractmethod + def to_pydantic(self, current_attempt=1): + """Convert text to pydantic.""" + pass + + @abstractmethod + def to_json(self, current_attempt=1): + """Convert text to json.""" + pass + + @property + @abstractmethod + def is_llama(self) -> bool: + """Return if llm provided is of llama from groq.""" + pass diff --git a/squadai/agents/agent_builder/utilities/base_token_process.py b/squadai/agents/agent_builder/utilities/base_token_process.py new file mode 100644 index 0000000..55c4b0d --- /dev/null +++ b/squadai/agents/agent_builder/utilities/base_token_process.py @@ -0,0 +1,27 @@ +from squadai.types.usage_metrics import UsageMetrics + + +class TokenProcess: + total_tokens: int = 0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + successful_requests: int = 0 + + def sum_prompt_tokens(self, tokens: int): + self.prompt_tokens = self.prompt_tokens + tokens + self.total_tokens = self.total_tokens + tokens + + def sum_completion_tokens(self, tokens: int): + self.completion_tokens = self.completion_tokens + tokens + self.total_tokens = self.total_tokens + tokens + + def sum_successful_requests(self, requests: int): + self.successful_requests = self.successful_requests + requests + + def get_summary(self) -> UsageMetrics: + return UsageMetrics( + total_tokens=self.total_tokens, + prompt_tokens=self.prompt_tokens, + completion_tokens=self.completion_tokens, + successful_requests=self.successful_requests, + ) diff --git a/squadai/agents/cache/__init__.py b/squadai/agents/cache/__init__.py new file mode 100644 index 0000000..6b4d200 --- /dev/null +++ b/squadai/agents/cache/__init__.py @@ -0,0 +1,3 @@ +from .cache_handler import CacheHandler + +__all__ = ["CacheHandler"] diff --git a/squadai/agents/cache/cache_handler.py b/squadai/agents/cache/cache_handler.py new file mode 100644 index 0000000..09dd76f --- /dev/null +++ b/squadai/agents/cache/cache_handler.py @@ -0,0 +1,15 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel, PrivateAttr + + +class CacheHandler(BaseModel): + """Callback handler for tool usage.""" + + _cache: Dict[str, Any] = PrivateAttr(default_factory=dict) + + def add(self, tool, input, output): + self._cache[f"{tool}-{input}"] = output + + def read(self, tool, input) -> Optional[str]: + return self._cache.get(f"{tool}-{input}") diff --git a/squadai/agents/executor.py b/squadai/agents/executor.py new file mode 100644 index 0000000..227d932 --- /dev/null +++ b/squadai/agents/executor.py @@ -0,0 +1,397 @@ +import threading +import time +from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union + +import click +from langchain.agents import AgentExecutor +from langchain.agents.agent import ExceptionTool +from langchain.callbacks.manager import CallbackManagerForChainRun +from langchain.chains.summarize import load_summarize_chain +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_core.agents import AgentAction, AgentFinish, AgentStep +from langchain_core.exceptions import OutputParserException +from langchain_core.tools import BaseTool +from langchain_core.utils.input import get_color_mapping +from pydantic import InstanceOf + +from squadai.agents.agent_builder.base_agent_executor_mixin import SquadAgentExecutorMixin +from squadai.agents.tools_handler import ToolsHandler +from squadai.tools.tool_usage import ToolUsage, ToolUsageErrorException +from squadai.utilities import I18N +from squadai.utilities.constants import TRAINING_DATA_FILE +from squadai.utilities.exceptions.context_window_exceeding_exception import ( + LLMContextLengthExceededException, +) +from squadai.utilities.logger import Logger +from squadai.utilities.training_handler import SquadTrainingHandler + + +class SquadAgentExecutor(AgentExecutor, SquadAgentExecutorMixin): + _i18n: I18N = I18N() + should_ask_for_human_input: bool = False + llm: Any = None + iterations: int = 0 + task: Any = None + tools_description: str = "" + tools_names: str = "" + original_tools: List[Any] = [] + squad_agent: Any = None + squad: Any = None + function_calling_llm: Any = None + request_within_rpm_limit: Any = None + tools_handler: Optional[InstanceOf[ToolsHandler]] = None + max_iterations: Optional[int] = 15 + have_forced_answer: bool = False + force_answer_max_iterations: Optional[int] = None # type: ignore # Incompatible types in assignment (expression has type "int | None", base class "SquadAgentExecutorMixin" defined the type as "int") + step_callback: Optional[Any] = None + system_template: Optional[str] = None + prompt_template: Optional[str] = None + response_template: Optional[str] = None + _logger: Logger = Logger() + _fit_context_window_strategy: Optional[Literal["summarize"]] = "summarize" + + def _call( + self, + inputs: Dict[str, str], + run_manager: Optional[CallbackManagerForChainRun] = None, + ) -> Dict[str, Any]: + """Run text through and get agent response.""" + # Construct a mapping of tool name to tool for easy lookup + name_to_tool_map = {tool.name: tool for tool in self.tools} + # We construct a mapping from each tool to a color, used for logging. + color_mapping = get_color_mapping( + [tool.name.casefold() for tool in self.tools], + excluded_colors=["green", "red"], + ) + intermediate_steps: List[Tuple[AgentAction, str]] = [] + # Allowing human input given task setting + if self.task and self.task.human_input: + self.should_ask_for_human_input = True + + # Let's start tracking the number of iterations and time elapsed + self.iterations = 0 + time_elapsed = 0.0 + start_time = time.time() + + # We now enter the agent loop (until it returns something). + while self._should_continue(self.iterations, time_elapsed): + if not self.request_within_rpm_limit or self.request_within_rpm_limit(): + next_step_output = self._take_next_step( + name_to_tool_map, + color_mapping, + inputs, + intermediate_steps, + run_manager=run_manager, + ) + + if self.step_callback: + self.step_callback(next_step_output) + + if isinstance(next_step_output, AgentFinish): + # Creating long term memory + create_long_term_memory = threading.Thread( + target=self._create_long_term_memory, args=(next_step_output,) + ) + create_long_term_memory.start() + + return self._return( + next_step_output, intermediate_steps, run_manager=run_manager + ) + + intermediate_steps.extend(next_step_output) + + if len(next_step_output) == 1: + next_step_action = next_step_output[0] + # See if tool should return directly + tool_return = self._get_tool_return(next_step_action) + if tool_return is not None: + return self._return( + tool_return, intermediate_steps, run_manager=run_manager + ) + + self.iterations += 1 + time_elapsed = time.time() - start_time + output = self.agent.return_stopped_response( + self.early_stopping_method, intermediate_steps, **inputs + ) + + return self._return(output, intermediate_steps, run_manager=run_manager) + + def _iter_next_step( + self, + name_to_tool_map: Dict[str, BaseTool], + color_mapping: Dict[str, str], + inputs: Dict[str, str], + intermediate_steps: List[Tuple[AgentAction, str]], + run_manager: Optional[CallbackManagerForChainRun] = None, + ) -> Iterator[Union[AgentFinish, AgentAction, AgentStep]]: + """Take a single step in the thought-action-observation loop. + + Override this to take control of how the agent makes and acts on choices. + """ + try: + if self._should_force_answer(): + error = self._i18n.errors("force_final_answer") + output = AgentAction("_Exception", error, error) + self.have_forced_answer = True + yield AgentStep(action=output, observation=error) + return + + intermediate_steps = self._prepare_intermediate_steps(intermediate_steps) + + # Call the LLM to see what to do. + output = self.agent.plan( + intermediate_steps, + callbacks=run_manager.get_child() if run_manager else None, + **inputs, + ) + + except OutputParserException as e: + if isinstance(self.handle_parsing_errors, bool): + raise_error = not self.handle_parsing_errors + else: + raise_error = False + if raise_error: + raise ValueError( + "An output parsing error occurred. " + "In order to pass this error back to the agent and have it try " + "again, pass `handle_parsing_errors=True` to the AgentExecutor. " + f"This is the error: {str(e)}" + ) + str(e) + if isinstance(self.handle_parsing_errors, bool): + if e.send_to_llm: + observation = f"\n{str(e.observation)}" + str(e.llm_output) + else: + observation = "" + elif isinstance(self.handle_parsing_errors, str): + observation = f"\n{self.handle_parsing_errors}" + elif callable(self.handle_parsing_errors): + observation = f"\n{self.handle_parsing_errors(e)}" + else: + raise ValueError("Got unexpected type of `handle_parsing_errors`") + output = AgentAction("_Exception", observation, "") + + if run_manager: + run_manager.on_agent_action(output, color="green") + + tool_run_kwargs = self.agent.tool_run_logging_kwargs() + observation = ExceptionTool().run( + output.tool_input, + verbose=False, + color=None, + callbacks=run_manager.get_child() if run_manager else None, + **tool_run_kwargs, + ) + + if self._should_force_answer(): + error = self._i18n.errors("force_final_answer") + output = AgentAction("_Exception", error, error) + yield AgentStep(action=output, observation=error) + return + + yield AgentStep(action=output, observation=observation) + return + + except Exception as e: + if LLMContextLengthExceededException(str(e))._is_context_limit_error( + str(e) + ): + output = self._handle_context_length_error( + intermediate_steps, run_manager, inputs + ) + + if isinstance(output, AgentFinish): + yield output + elif isinstance(output, list): + for step in output: + yield step + return + + raise e + + # If the tool chosen is the finishing tool, then we end and return. + if isinstance(output, AgentFinish): + if self.should_ask_for_human_input: + human_feedback = self._ask_human_input(output.return_values["output"]) + + if self.squad and self.squad._train: + self._handle_squad_training_output(output, human_feedback) + + # Making sure we only ask for it once, so disabling for the next thought loop + self.should_ask_for_human_input = False + action = AgentAction( + tool="Human Input", tool_input=human_feedback, log=output.log + ) + + yield AgentStep( + action=action, + observation=self._i18n.slice("human_feedback").format( + human_feedback=human_feedback + ), + ) + return + + else: + if self.squad and self.squad._train: + self._handle_squad_training_output(output) + + yield output + return + + self._create_short_term_memory(output) + + actions: List[AgentAction] + actions = [output] if isinstance(output, AgentAction) else output + yield from actions + + for agent_action in actions: + if run_manager: + run_manager.on_agent_action(agent_action, color="green") + + tool_usage = ToolUsage( + tools_handler=self.tools_handler, # type: ignore # Argument "tools_handler" to "ToolUsage" has incompatible type "ToolsHandler | None"; expected "ToolsHandler" + tools=self.tools, # type: ignore # Argument "tools" to "ToolUsage" has incompatible type "Sequence[BaseTool]"; expected "list[BaseTool]" + original_tools=self.original_tools, + tools_description=self.tools_description, + tools_names=self.tools_names, + function_calling_llm=self.function_calling_llm, + task=self.task, + agent=self.squad_agent, + action=agent_action, + ) + + tool_calling = tool_usage.parse(agent_action.log) + + if isinstance(tool_calling, ToolUsageErrorException): + observation = tool_calling.message + else: + if tool_calling.tool_name.casefold().strip() in [ + name.casefold().strip() for name in name_to_tool_map + ] or tool_calling.tool_name.casefold().replace("_", " ") in [ + name.casefold().strip() for name in name_to_tool_map + ]: + observation = tool_usage.use(tool_calling, agent_action.log) + else: + observation = self._i18n.errors("wrong_tool_name").format( + tool=tool_calling.tool_name, + tools=", ".join([tool.name.casefold() for tool in self.tools]), + ) + yield AgentStep(action=agent_action, observation=observation) + + def _handle_squad_training_output( + self, output: AgentFinish, human_feedback: str | None = None + ) -> None: + """Function to handle the process of the training data.""" + agent_id = str(self.squad_agent.id) + + if ( + SquadTrainingHandler(TRAINING_DATA_FILE).load() + and not self.should_ask_for_human_input + ): + training_data = SquadTrainingHandler(TRAINING_DATA_FILE).load() + if training_data.get(agent_id): + training_data[agent_id][self.squad._train_iteration][ + "improved_output" + ] = output.return_values["output"] + SquadTrainingHandler(TRAINING_DATA_FILE).save(training_data) + + if self.should_ask_for_human_input and human_feedback is not None: + training_data = { + "initial_output": output.return_values["output"], + "human_feedback": human_feedback, + "agent": agent_id, + "agent_role": self.squad_agent.role, + } + SquadTrainingHandler(TRAINING_DATA_FILE).append( + self.squad._train_iteration, agent_id, training_data + ) + + def _handle_context_length( + self, intermediate_steps: List[Tuple[AgentAction, str]] + ) -> List[Tuple[AgentAction, str]]: + text = intermediate_steps[0][1] + original_action = intermediate_steps[0][0] + + text_splitter = RecursiveCharacterTextSplitter( + separators=["\n\n", "\n"], + chunk_size=8000, + chunk_overlap=500, + ) + + if self._fit_context_window_strategy == "summarize": + docs = text_splitter.create_documents([text]) + self._logger.log( + "debug", + "Summarizing Content, it is recommended to use a RAG tool", + color="bold_blue", + ) + summarize_chain = load_summarize_chain( + self.llm, chain_type="map_reduce", verbose=True + ) + summarized_docs = [] + for doc in docs: + summary = summarize_chain.invoke( + {"input_documents": [doc]}, return_only_outputs=True + ) + + summarized_docs.append(summary["output_text"]) + + formatted_results = "\n\n".join(summarized_docs) + summary_step = AgentStep( + action=AgentAction( + tool=original_action.tool, + tool_input=original_action.tool_input, + log=original_action.log, + ), + observation=formatted_results, + ) + summary_tuple = (summary_step.action, summary_step.observation) + return [summary_tuple] + + return intermediate_steps + + def _handle_context_length_error( + self, + intermediate_steps: List[Tuple[AgentAction, str]], + run_manager: Optional[CallbackManagerForChainRun], + inputs: Dict[str, str], + ) -> Union[AgentFinish, List[AgentStep]]: + self._logger.log( + "debug", + "Context length exceeded. Asking user if they want to use summarize prompt to fit, this will reduce context length.", + color="yellow", + ) + user_choice = click.confirm( + "Context length exceeded. Do you want to summarize the text to fit models context window?" + ) + if user_choice: + self._logger.log( + "debug", + "Context length exceeded. Using summarize prompt to fit, this will reduce context length.", + color="bold_blue", + ) + intermediate_steps = self._handle_context_length(intermediate_steps) + + output = self.agent.plan( + intermediate_steps, + callbacks=run_manager.get_child() if run_manager else None, + **inputs, + ) + + if isinstance(output, AgentFinish): + return output + elif isinstance(output, AgentAction): + return [AgentStep(action=output, observation=None)] + else: + return [AgentStep(action=action, observation=None) for action in output] + else: + self._logger.log( + "debug", + "Context length exceeded. Consider using smaller text or RAG tools from squadai_tools.", + color="red", + ) + raise SystemExit( + "Context length exceeded and user opted not to summarize. Consider using smaller text or RAG tools from squadai_tools." + ) diff --git a/squadai/agents/parser.py b/squadai/agents/parser.py new file mode 100644 index 0000000..17e25c6 --- /dev/null +++ b/squadai/agents/parser.py @@ -0,0 +1,121 @@ +import re +from typing import Any, Union + +from json_repair import repair_json +from langchain.agents.output_parsers import ReActSingleInputOutputParser +from langchain_core.agents import AgentAction, AgentFinish +from langchain_core.exceptions import OutputParserException + +from squadai.utilities import I18N + +FINAL_ANSWER_ACTION = "Final Answer:" +MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE = "I did it wrong. Invalid Format: I missed the 'Action:' after 'Thought:'. I will do right next, and don't use a tool I have already used.\n" +MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE = "I did it wrong. Invalid Format: I missed the 'Action Input:' after 'Action:'. I will do right next, and don't use a tool I have already used.\n" +FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE = "I did it wrong. Tried to both perform Action and give a Final Answer at the same time, I must do one or the other" + + +class SquadAgentParser(ReActSingleInputOutputParser): + """Parses ReAct-style LLM calls that have a single tool input. + + Expects output to be in one of two formats. + + If the output signals that an action should be taken, + should be in the below format. This will result in an AgentAction + being returned. + + Thought: agent thought here + Action: search + Action Input: what is the temperature in SF? + + If the output signals that a final answer should be given, + should be in the below format. This will result in an AgentFinish + being returned. + + Thought: agent thought here + Final Answer: The temperature is 100 degrees + """ + + _i18n: I18N = I18N() + agent: Any = None + + def parse(self, text: str) -> Union[AgentAction, AgentFinish]: + includes_answer = FINAL_ANSWER_ACTION in text + regex = ( + r"Action\s*\d*\s*:[\s]*(.*?)[\s]*Action\s*\d*\s*Input\s*\d*\s*:[\s]*(.*)" + ) + action_match = re.search(regex, text, re.DOTALL) + if action_match: + if includes_answer: + raise OutputParserException( + f"{FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE}: {text}" + ) + action = action_match.group(1) + clean_action = self._clean_action(action) + + action_input = action_match.group(2).strip() + + tool_input = action_input.strip(" ").strip('"') + safe_tool_input = self._safe_repair_json(tool_input) + + return AgentAction(clean_action, safe_tool_input, text) + + elif includes_answer: + return AgentFinish( + {"output": text.split(FINAL_ANSWER_ACTION)[-1].strip()}, text + ) + + if not re.search(r"Action\s*\d*\s*:[\s]*(.*?)", text, re.DOTALL): + self.agent.increment_formatting_errors() + raise OutputParserException( + f"Could not parse LLM output: `{text}`", + observation=f"{MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE}\n{self._i18n.slice('final_answer_format')}", + llm_output=text, + send_to_llm=True, + ) + elif not re.search( + r"[\s]*Action\s*\d*\s*Input\s*\d*\s*:[\s]*(.*)", text, re.DOTALL + ): + self.agent.increment_formatting_errors() + raise OutputParserException( + f"Could not parse LLM output: `{text}`", + observation=MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE, + llm_output=text, + send_to_llm=True, + ) + else: + format = self._i18n.slice("format_without_tools") + error = f"{format}" + self.agent.increment_formatting_errors() + raise OutputParserException( + error, + observation=error, + llm_output=text, + send_to_llm=True, + ) + + def _clean_action(self, text: str) -> str: + """Clean action string by removing non-essential formatting characters.""" + return re.sub(r"^\s*\*+\s*|\s*\*+\s*$", "", text).strip() + + def _safe_repair_json(self, tool_input: str) -> str: + UNABLE_TO_REPAIR_JSON_RESULTS = ['""', "{}"] + + # Skip repair if the input starts and ends with square brackets + # Explanation: The JSON parser has issues handling inputs that are enclosed in square brackets ('[]'). + # These are typically valid JSON arrays or strings that do not require repair. Attempting to repair such inputs + # might lead to unintended alterations, such as wrapping the entire input in additional layers or modifying + # the structure in a way that changes its meaning. By skipping the repair for inputs that start and end with + # square brackets, we preserve the integrity of these valid JSON structures and avoid unnecessary modifications. + if tool_input.startswith("[") and tool_input.endswith("]"): + return tool_input + + # Before repair, handle common LLM issues: + # 1. Replace """ with " to avoid JSON parser errors + + tool_input = tool_input.replace('"""', '"') + + result = repair_json(tool_input) + if result in UNABLE_TO_REPAIR_JSON_RESULTS: + return tool_input + + return str(result) diff --git a/squadai/agents/tools_handler.py b/squadai/agents/tools_handler.py new file mode 100644 index 0000000..c82f7de --- /dev/null +++ b/squadai/agents/tools_handler.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union + +from ..tools.cache_tools import CacheTools +from ..tools.tool_calling import InstructorToolCalling, ToolCalling +from .cache.cache_handler import CacheHandler + + +class ToolsHandler: + """Callback handler for tool usage.""" + + last_used_tool: ToolCalling = {} # type: ignore # BUG?: Incompatible types in assignment (expression has type "Dict[...]", variable has type "ToolCalling") + cache: Optional[CacheHandler] + + def __init__(self, cache: Optional[CacheHandler] = None): + """Initialize the callback handler.""" + self.cache = cache + self.last_used_tool = {} # type: ignore # BUG?: same as above + + def on_tool_use( + self, + calling: Union[ToolCalling, InstructorToolCalling], + output: str, + should_cache: bool = True, + ) -> Any: + """Run when tool ends running.""" + self.last_used_tool = calling # type: ignore # BUG?: Incompatible types in assignment (expression has type "Union[ToolCalling, InstructorToolCalling]", variable has type "ToolCalling") + if self.cache and should_cache and calling.tool_name != CacheTools().name: + self.cache.add( + tool=calling.tool_name, + input=calling.arguments, + output=output, + ) diff --git a/squadai/cli/__init__.py b/squadai/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/cli.py b/squadai/cli/cli.py new file mode 100644 index 0000000..f031e69 --- /dev/null +++ b/squadai/cli/cli.py @@ -0,0 +1,172 @@ +import os +from typing import Optional + +import click +import pkg_resources +from dotenv import load_dotenv + +from squadai.cli.create_squad import create_squad +from squadai.cli.create_pipeline import create_pipeline +from squadai.memory.storage.kickoff_task_outputs_storage import ( + KickoffTaskOutputsSQLiteStorage, +) + +from .evaluate_squad import evaluate_squad +from .install_squad import install_squad +from .replay_from_task import replay_task_command +from .reset_memories_command import reset_memories_command +from .run_squad import run_squad +from .train_squad import train_squad + +load_dotenv() + + +@click.group() +def squadai(): + """Top-level command group for squadai.""" + + +@squadai.command() +@click.argument("type", type=click.Choice(["squad", "pipeline"])) +@click.argument("name") +@click.option( + "--router", is_flag=True, help="Create a pipeline with router functionality" +) +def create(type, name, router): + """Create a new squad or pipeline.""" + if type == "squad": + create_squad(name) + elif type == "pipeline": + create_pipeline(name, router) + else: + click.secho("Error: Invalid type. Must be 'squad' or 'pipeline'.", fg="red") + + +@squadai.command() +@click.option( + "-n", + "--n_iterations", + type=int, + default=5, + help="Number of iterations to train the squad", +) +@click.option( + "-f", + "--filename", + type=str, + default="trained_agents_data.pkl", + help="Path to a custom file for training", +) +def train(n_iterations: int, filename: str): + """Train the squad.""" + click.echo(f"Training the Squad for {n_iterations} iterations") + train_squad(n_iterations, filename) + + +@squadai.command() +@click.option( + "-t", + "--task_id", + type=str, + help="Replay the squad from this task ID, including all subsequent tasks.", +) +def replay(task_id: str) -> None: + """ + Replay the squad execution from a specific task. + + Args: + task_id (str): The ID of the task to replay from. + """ + try: + click.echo(f"Replaying the squad from task {task_id}") + replay_task_command(task_id) + except Exception as e: + click.echo(f"An error occurred while replaying: {e}", err=True) + + +@squadai.command() +def log_tasks_outputs() -> None: + """ + Retrieve your latest squad.kickoff() task outputs. + """ + try: + storage = KickoffTaskOutputsSQLiteStorage() + tasks = storage.load() + + if not tasks: + click.echo( + "No task outputs found. Only squad kickoff task outputs are logged." + ) + return + + for index, task in enumerate(tasks, 1): + click.echo(f"Task {index}: {task['task_id']}") + click.echo(f"Description: {task['expected_output']}") + click.echo("------") + + except Exception as e: + click.echo(f"An error occurred while logging task outputs: {e}", err=True) + + +@squadai.command() +@click.option("-l", "--long", is_flag=True, help="Reset LONG TERM memory") +@click.option("-s", "--short", is_flag=True, help="Reset SHORT TERM memory") +@click.option("-e", "--entities", is_flag=True, help="Reset ENTITIES memory") +@click.option( + "-k", + "--kickoff-outputs", + is_flag=True, + help="Reset LATEST KICKOFF TASK OUTPUTS", +) +@click.option("-a", "--all", is_flag=True, help="Reset ALL memories") +def reset_memories(long, short, entities, kickoff_outputs, all): + """ + Reset the squad memories (long, short, entity, latest_squad_kickoff_ouputs). This will delete all the data saved. + """ + try: + if not all and not (long or short or entities or kickoff_outputs): + click.echo( + "Please specify at least one memory type to reset using the appropriate flags." + ) + return + reset_memories_command(long, short, entities, kickoff_outputs, all) + except Exception as e: + click.echo(f"An error occurred while resetting memories: {e}", err=True) + + +@squadai.command() +@click.option( + "-n", + "--n_iterations", + type=int, + default=3, + help="Number of iterations to Test the squad", +) +@click.option( + "-m", + "--model", + type=str, + default=os.getenv("GROQ_MODEL_NAME"), + help="LLM Model to run the tests on the Squad. For now only accepting only Llama model.", +) +def test(n_iterations: int, model: str): + """Test the squad and evaluate the results.""" + click.echo(f"Testing the squad for {n_iterations} iterations with model {model}") + evaluate_squad(n_iterations, model) + + +@squadai.command() +def install(): + """Install the Squad.""" + install_squad() + + +@squadai.command() +def run(): + """Run the Squad.""" + click.echo("Running the Squad") + run_squad() + + +if __name__ == "__main__": + squadai() diff --git a/squadai/cli/create_pipeline.py b/squadai/cli/create_pipeline.py new file mode 100644 index 0000000..5df1b99 --- /dev/null +++ b/squadai/cli/create_pipeline.py @@ -0,0 +1,107 @@ +import shutil +from pathlib import Path + +import click + + +def create_pipeline(name, router=False): + """Create a new pipeline project.""" + folder_name = name.replace(" ", "_").replace("-", "_").lower() + class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "") + + click.secho(f"Creating pipeline {folder_name}...", fg="green", bold=True) + + project_root = Path(folder_name) + if project_root.exists(): + click.secho(f"Error: Folder {folder_name} already exists.", fg="red") + return + + # Create directory structure + (project_root / "src" / folder_name).mkdir(parents=True) + (project_root / "src" / folder_name / "pipelines").mkdir(parents=True) + (project_root / "src" / folder_name / "squads").mkdir(parents=True) + (project_root / "src" / folder_name / "tools").mkdir(parents=True) + (project_root / "tests").mkdir(exist_ok=True) + + # Create .env file + with open(project_root / ".env", "w") as file: + file.write("GROQ_API_KEY=YOUR_API_KEY") + + package_dir = Path(__file__).parent + template_folder = "pipeline_router" if router else "pipeline" + templates_dir = package_dir / "templates" / template_folder + + # List of template files to copy + root_template_files = [".gitignore", "pyproject.toml", "README.md"] + src_template_files = ["__init__.py", "main.py"] + tools_template_files = ["tools/__init__.py", "tools/custom_tool.py"] + + if router: + squad_folders = [ + "classifier_squad", + "normal_squad", + "urgent_squad", + ] + pipelines_folders = [ + "pipelines/__init__.py", + "pipelines/pipeline_classifier.py", + "pipelines/pipeline_normal.py", + "pipelines/pipeline_urgent.py", + ] + else: + squad_folders = [ + "research_squad", + "write_linkedin_squad", + "write_x_squad", + ] + pipelines_folders = ["pipelines/__init__.py", "pipelines/pipeline.py"] + + def process_file(src_file, dst_file): + with open(src_file, "r") as file: + content = file.read() + + content = content.replace("{{name}}", name) + content = content.replace("{{squad_name}}", class_name) + content = content.replace("{{folder_name}}", folder_name) + content = content.replace("{{pipeline_name}}", class_name) + + with open(dst_file, "w") as file: + file.write(content) + + # Copy and process root template files + for file_name in root_template_files: + src_file = templates_dir / file_name + dst_file = project_root / file_name + process_file(src_file, dst_file) + + # Copy and process src template files + for file_name in src_template_files: + src_file = templates_dir / file_name + dst_file = project_root / "src" / folder_name / file_name + process_file(src_file, dst_file) + + # Copy tools files + for file_name in tools_template_files: + src_file = templates_dir / file_name + dst_file = project_root / "src" / folder_name / file_name + shutil.copy(src_file, dst_file) + + # Copy pipelines folders + for file_name in pipelines_folders: + src_file = templates_dir / file_name + dst_file = project_root / "src" / folder_name / file_name + process_file(src_file, dst_file) + + # Copy squad folders + for squad_folder in squad_folders: + src_squad_folder = templates_dir / "squads" / squad_folder + dst_squad_folder = project_root / "src" / folder_name / "squads" / squad_folder + if src_squad_folder.exists(): + shutil.copytree(src_squad_folder, dst_squad_folder) + else: + click.secho( + f"Warning: Squad folder {squad_folder} not found in template.", + fg="yellow", + ) + + click.secho(f"Pipeline {name} created successfully!", fg="green", bold=True) diff --git a/squadai/cli/create_squad.py b/squadai/cli/create_squad.py new file mode 100644 index 0000000..18fbd2e --- /dev/null +++ b/squadai/cli/create_squad.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import click + +from squadai.cli.utils import copy_template + + +def create_squad(name, parent_folder=None): + """Create a new squad.""" + folder_name = name.replace(" ", "_").replace("-", "_").lower() + class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "") + + if parent_folder: + folder_path = Path(parent_folder) / folder_name + else: + folder_path = Path(folder_name) + + click.secho( + f"Creating {'squad' if parent_folder else 'folder'} {folder_name}...", + fg="green", + bold=True, + ) + + if not folder_path.exists(): + folder_path.mkdir(parents=True) + (folder_path / "tests").mkdir(exist_ok=True) + if not parent_folder: + (folder_path / "src" / folder_name).mkdir(parents=True) + (folder_path / "src" / folder_name / "tools").mkdir(parents=True) + (folder_path / "src" / folder_name / "config").mkdir(parents=True) + with open(folder_path / ".env", "w") as file: + file.write("GROQ_API_KEY=YOUR_API_KEY") + else: + click.secho( + f"\tFolder {folder_name} already exists. Please choose a different name.", + fg="red", + ) + return + + package_dir = Path(__file__).parent + templates_dir = package_dir / "templates" / "squad" + + # List of template files to copy + root_template_files = ( + [".gitignore", "pyproject.toml", "README.md"] if not parent_folder else [] + ) + tools_template_files = ["tools/custom_tool.py", "tools/__init__.py"] + config_template_files = ["config/agents.yaml", "config/tasks.yaml"] + src_template_files = ( + ["__init__.py", "main.py", "squad.py"] if not parent_folder else ["squad.py"] + ) + + for file_name in root_template_files: + src_file = templates_dir / file_name + dst_file = folder_path / file_name + copy_template(src_file, dst_file, name, class_name, folder_name) + + src_folder = folder_path / "src" / folder_name if not parent_folder else folder_path + + for file_name in src_template_files: + src_file = templates_dir / file_name + dst_file = src_folder / file_name + copy_template(src_file, dst_file, name, class_name, folder_name) + + if not parent_folder: + for file_name in tools_template_files + config_template_files: + src_file = templates_dir / file_name + dst_file = src_folder / file_name + copy_template(src_file, dst_file, name, class_name, folder_name) + + click.secho(f"Squad {name} created successfully!", fg="green", bold=True) diff --git a/squadai/cli/evaluate_squad.py b/squadai/cli/evaluate_squad.py new file mode 100644 index 0000000..9ba378c --- /dev/null +++ b/squadai/cli/evaluate_squad.py @@ -0,0 +1,30 @@ +import subprocess + +import click + + +def evaluate_squad(n_iterations: int, model: str) -> None: + """ + Test and Evaluate the squad by running a command in the Poetry environment. + + Args: + n_iterations (int): The number of iterations to test the squad. + model (str): The model to test the squad with. + """ + command = ["poetry", "run", "test", str(n_iterations), model] + + try: + if n_iterations <= 0: + raise ValueError("The number of iterations must be a positive integer.") + + result = subprocess.run(command, capture_output=False, text=True, check=True) + + if result.stderr: + click.echo(result.stderr, err=True) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while testing the squad: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/install_squad.py b/squadai/cli/install_squad.py new file mode 100644 index 0000000..c4ea258 --- /dev/null +++ b/squadai/cli/install_squad.py @@ -0,0 +1,21 @@ +import subprocess + +import click + + +def install_squad() -> None: + """ + Install the squad by running the Poetry command to lock and install. + """ + try: + subprocess.run(["poetry", "lock"], check=True, capture_output=False, text=True) + subprocess.run( + ["poetry", "install"], check=True, capture_output=False, text=True + ) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while running the squad: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/replay_from_task.py b/squadai/cli/replay_from_task.py new file mode 100644 index 0000000..9ee2868 --- /dev/null +++ b/squadai/cli/replay_from_task.py @@ -0,0 +1,24 @@ +import subprocess +import click + + +def replay_task_command(task_id: str) -> None: + """ + Replay the squad execution from a specific task. + + Args: + task_id (str): The ID of the task to replay from. + """ + command = ["poetry", "run", "replay", task_id] + + try: + result = subprocess.run(command, capture_output=False, text=True, check=True) + if result.stderr: + click.echo(result.stderr, err=True) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while replaying the task: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/reset_memories_command.py b/squadai/cli/reset_memories_command.py new file mode 100644 index 0000000..b5302c4 --- /dev/null +++ b/squadai/cli/reset_memories_command.py @@ -0,0 +1,49 @@ +import subprocess +import click + +from squadai.memory.entity.entity_memory import EntityMemory +from squadai.memory.long_term.long_term_memory import LongTermMemory +from squadai.memory.short_term.short_term_memory import ShortTermMemory +from squadai.utilities.task_output_storage_handler import TaskOutputStorageHandler + + +def reset_memories_command(long, short, entity, kickoff_outputs, all) -> None: + """ + Reset the squad memories. + + Args: + long (bool): Whether to reset the long-term memory. + short (bool): Whether to reset the short-term memory. + entity (bool): Whether to reset the entity memory. + kickoff_outputs (bool): Whether to reset the latest kickoff task outputs. + all (bool): Whether to reset all memories. + """ + + try: + if all: + ShortTermMemory().reset() + EntityMemory().reset() + LongTermMemory().reset() + TaskOutputStorageHandler().reset() + click.echo("All memories have been reset.") + else: + if long: + LongTermMemory().reset() + click.echo("Long term memory has been reset.") + + if short: + ShortTermMemory().reset() + click.echo("Short term memory has been reset.") + if entity: + EntityMemory().reset() + click.echo("Entity memory has been reset.") + if kickoff_outputs: + TaskOutputStorageHandler().reset() + click.echo("Latest Kickoff outputs stored has been reset.") + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while resetting the memories: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/run_squad.py b/squadai/cli/run_squad.py new file mode 100644 index 0000000..da36d6b --- /dev/null +++ b/squadai/cli/run_squad.py @@ -0,0 +1,23 @@ +import subprocess + +import click + + +def run_squad() -> None: + """ + Run the squad by running a command in the Poetry environment. + """ + command = ["poetry", "run", "run_squad"] + + try: + result = subprocess.run(command, capture_output=False, text=True, check=True) + + if result.stderr: + click.echo(result.stderr, err=True) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while running the squad: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/templates/__init__.py b/squadai/cli/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/.gitignore b/squadai/cli/templates/pipeline/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/squadai/cli/templates/pipeline/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/squadai/cli/templates/pipeline/README.md b/squadai/cli/templates/pipeline/README.md new file mode 100644 index 0000000..2f14b6e --- /dev/null +++ b/squadai/cli/templates/pipeline/README.md @@ -0,0 +1,57 @@ +# {{squad_name}} Squad + +Welcome to the {{squad_name}} Squad project, powered by [squadAI](https://squadai.com). This template is designed to help you set up a multi-agent AI system with ease, leveraging the powerful and flexible framework provided by squadAI. Our goal is to enable your agents to collaborate effectively on complex tasks, maximizing their collective intelligence and capabilities. + +## Installation + +Ensure you have Python >=3.10 <=3.13 installed on your system. This project uses [Poetry](https://python-poetry.org/) for dependency management and package handling, offering a seamless setup and execution experience. + +First, if you haven't already, install Poetry: + +```bash +pip install poetry +``` + +Next, navigate to your project directory and install the dependencies: + +1. First lock the dependencies and then install them: + +```bash +squadai install +``` + +### Customizing + +**Add your `OPENAI_API_KEY` into the `.env` file** + +- Modify `src/{{folder_name}}/config/agents.yaml` to define your agents +- Modify `src/{{folder_name}}/config/tasks.yaml` to define your tasks +- Modify `src/{{folder_name}}/squad.py` to add your own logic, tools and specific args +- Modify `src/{{folder_name}}/main.py` to add custom inputs for your agents and tasks + +## Running the Project + +To kickstart your squad of AI agents and begin task execution, run this from the root folder of your project: + +```bash +squadai run +``` + +This command initializes the {{name}} Squad, assembling the agents and assigning them tasks as defined in your configuration. + +This example, unmodified, will run the create a `report.md` file with the output of a research on LLMs in the root folder. + +## Understanding Your Squad + +The {{name}} Squad is composed of multiple AI agents, each with unique roles, goals, and tools. These agents collaborate on a series of tasks, defined in `config/tasks.yaml`, leveraging their collective skills to achieve complex objectives. The `config/agents.yaml` file outlines the capabilities and configurations of each agent in your squad. + +## Support + +For support, questions, or feedback regarding the {{squad_name}} Squad or squadAI. + +- Visit our [documentation](https://docs.squadai.com) +- Reach out to us through our [GitHub repository](https://github.com/joaomdmoura/squadai) +- [Join our Discord](https://discord.com/invite/X4JWnZnxPb) +- [Chat with our docs](https://chatg.pt/DWjSBZn) + +Let's create wonders together with the power and simplicity of squadAI. diff --git a/squadai/cli/templates/pipeline/__init__.py b/squadai/cli/templates/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/crews/research_crew/config/agents.yaml b/squadai/cli/templates/pipeline/crews/research_crew/config/agents.yaml new file mode 100644 index 0000000..f8cf1f5 --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/research_crew/config/agents.yaml @@ -0,0 +1,19 @@ +researcher: + role: > + {topic} Senior Data Researcher + goal: > + Uncover cutting-edge developments in {topic} + backstory: > + You're a seasoned researcher with a knack for uncovering the latest + developments in {topic}. Known for your ability to find the most relevant + information and present it in a clear and concise manner. + +reporting_analyst: + role: > + {topic} Reporting Analyst + goal: > + Create detailed reports based on {topic} data analysis and research findings + backstory: > + You're a meticulous analyst with a keen eye for detail. You're known for + your ability to turn complex data into clear and concise reports, making + it easy for others to understand and act on the information you provide. diff --git a/squadai/cli/templates/pipeline/crews/research_crew/config/tasks.yaml b/squadai/cli/templates/pipeline/crews/research_crew/config/tasks.yaml new file mode 100644 index 0000000..e780918 --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/research_crew/config/tasks.yaml @@ -0,0 +1,16 @@ +research_task: + description: > + Conduct a thorough research about {topic} + Make sure you find any interesting and relevant information given + the current year is 2024. + expected_output: > + A list with 10 bullet points of the most relevant information about {topic} + agent: researcher + +reporting_task: + description: > + Review the context you got and expand each topic into a full section for a report. + Make sure the report is detailed and contains any and all relevant information. + expected_output: > + A fully fledge reports with a title, mains topics, each with a full section of information. + agent: reporting_analyst diff --git a/squadai/cli/templates/pipeline/crews/research_crew/research_crew.py b/squadai/cli/templates/pipeline/crews/research_crew/research_crew.py new file mode 100644 index 0000000..de1e6c7 --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/research_crew/research_crew.py @@ -0,0 +1,58 @@ +from pydantic import BaseModel +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from demo_pipeline.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + + +class ResearchReport(BaseModel): + """Research Report""" + title: str + body: str + +@SquadBase +class ResearchSquad(): + """Research Squad""" + agents_config = 'config/agents.yaml' + tasks_config = 'config/tasks.yaml' + + @agent + def researcher(self) -> Agent: + return Agent( + config=self.agents_config['researcher'], + verbose=True + ) + + @agent + def reporting_analyst(self) -> Agent: + return Agent( + config=self.agents_config['reporting_analyst'], + verbose=True + ) + + @task + def research_task(self) -> Task: + return Task( + config=self.tasks_config['research_task'], + ) + + @task + def reporting_task(self) -> Task: + return Task( + config=self.tasks_config['reporting_task'], + output_pydantic=ResearchReport + ) + + @squad + def squad(self) -> Squad: + """Creates the Research Squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) \ No newline at end of file diff --git a/squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/agents.yaml b/squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/agents.yaml new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/tasks.yaml b/squadai/cli/templates/pipeline/crews/write_linkedin_crew/config/tasks.yaml new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/crews/write_linkedin_crew/write_linkedin_crew.py b/squadai/cli/templates/pipeline/crews/write_linkedin_crew/write_linkedin_crew.py new file mode 100644 index 0000000..b0daf1e --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/write_linkedin_crew/write_linkedin_crew.py @@ -0,0 +1,51 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from {{folder_name}}.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + +@SquadBase +class WriteLinkedInSquad(): + """Research Squad""" + agents_config = 'config/agents.yaml' + tasks_config = 'config/tasks.yaml' + + @agent + def researcher(self) -> Agent: + return Agent( + config=self.agents_config['researcher'], + verbose=True + ) + + @agent + def reporting_analyst(self) -> Agent: + return Agent( + config=self.agents_config['reporting_analyst'], + verbose=True + ) + + @task + def research_task(self) -> Task: + return Task( + config=self.tasks_config['research_task'], + ) + + @task + def reporting_task(self) -> Task: + return Task( + config=self.tasks_config['reporting_task'], + output_file='report.md' + ) + + @squad + def squad(self) -> Squad: + """Creates the {{squad_name}} squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) \ No newline at end of file diff --git a/squadai/cli/templates/pipeline/crews/write_x_crew/config/agents.yaml b/squadai/cli/templates/pipeline/crews/write_x_crew/config/agents.yaml new file mode 100644 index 0000000..1401dcb --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/write_x_crew/config/agents.yaml @@ -0,0 +1,14 @@ +x_writer_agent: + role: > + Expert Social Media Content Creator specializing in short form written content + goal: > + Create viral-worthy, engaging short form posts that distill complex {topic} information + into compelling 280-character messages + backstory: > + You're a social media virtuoso with a particular talent for short form content. Your posts + consistently go viral due to your ability to craft hooks that stop users mid-scroll. + You've studied the techniques of social media masters like Justin Welsh, Dickie Bush, + Nicolas Cole, and Shaan Puri, incorporating their best practices into your own unique style. + Your superpower is taking intricate {topic} concepts and transforming them into + bite-sized, shareable content that resonates with a wide audience. You know exactly + how to structure a post for maximum impact and engagement. diff --git a/squadai/cli/templates/pipeline/crews/write_x_crew/config/tasks.yaml b/squadai/cli/templates/pipeline/crews/write_x_crew/config/tasks.yaml new file mode 100644 index 0000000..1ffbc20 --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/write_x_crew/config/tasks.yaml @@ -0,0 +1,22 @@ +write_x_task: + description: > + Using the research report provided, create an engaging short form post about {topic}. + Your post should have a great hook, summarize key points, and be structured for easy + consumption on a digital platform. The post must be under 280 characters. + Follow these guidelines: + 1. Start with an attention-grabbing hook + 2. Condense the main insights from the research + 3. Use clear, concise language + 4. Include a call-to-action or thought-provoking question if space allows + 5. Ensure the post flows well and is easy to read quickly + + Here is the title of the research report you will be using + + Title: {title} + Research: + {body} + + expected_output: > + A compelling X post under 280 characters that effectively summarizes the key findings + about {topic}, starts with a strong hook, and is optimized for engagement on the platform. + agent: x_writer_agent diff --git a/squadai/cli/templates/pipeline/crews/write_x_crew/write_x_crew.py b/squadai/cli/templates/pipeline/crews/write_x_crew/write_x_crew.py new file mode 100644 index 0000000..68ae0b8 --- /dev/null +++ b/squadai/cli/templates/pipeline/crews/write_x_crew/write_x_crew.py @@ -0,0 +1,36 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from demo_pipeline.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + + +@SquadBase +class WriteXSquad: + """Research Squad""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def x_writer_agent(self) -> Agent: + return Agent(config=self.agents_config["x_writer_agent"], verbose=True) + + @task + def write_x_task(self) -> Task: + return Task( + config=self.tasks_config["write_x_task"], + ) + + @squad + def squad(self) -> Squad: + """Creates the Write X Squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) diff --git a/squadai/cli/templates/pipeline/main.py b/squadai/cli/templates/pipeline/main.py new file mode 100644 index 0000000..3766933 --- /dev/null +++ b/squadai/cli/templates/pipeline/main.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +import asyncio +from {{folder_name}}.pipelines.pipeline import {{pipeline_name}}Pipeline + +async def run(): + """ + Run the pipeline. + """ + inputs = [ + {"topic": "AI wearables"}, + ] + pipeline = {{pipeline_name}}Pipeline() + results = await pipeline.kickoff(inputs) + + # Process and print results + for result in results: + print(f"Raw output: {result.raw}") + if result.json_dict: + print(f"JSON output: {result.json_dict}") + print("\n") + +def main(): + asyncio.run(run()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/squadai/cli/templates/pipeline/pipelines/__init__.py b/squadai/cli/templates/pipeline/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/pipelines/pipeline.py b/squadai/cli/templates/pipeline/pipelines/pipeline.py new file mode 100644 index 0000000..18074eb --- /dev/null +++ b/squadai/cli/templates/pipeline/pipelines/pipeline.py @@ -0,0 +1,87 @@ +""" +This pipeline file includes two different examples to demonstrate the flexibility of squadAI pipelines. + +Example 1: Two-Stage Pipeline +----------------------------- +This pipeline consists of two squads: +1. ResearchSquad: Performs research on a given topic. +2. WriteXSquad: Generates an X (Twitter) post based on the research findings. + +Key features: +- The ResearchSquad's final task uses output_json to store all research findings in a JSON object. +- This JSON object is then passed to the WriteXSquad, where tasks can access the research findings. + +Example 2: Two-Stage Pipeline with Parallel Execution +------------------------------------------------------- +This pipeline consists of three squads: +1. ResearchSquad: Performs research on a given topic. +2. WriteXSquad and WriteLinkedInSquad: Run in parallel, using the research findings to generate posts for X and LinkedIn, respectively. + +Key features: +- Demonstrates the ability to run multiple squads in parallel. +- Shows how to structure a pipeline with both sequential and parallel stages. + +Usage: +- To switch between examples, comment/uncomment the respective code blocks below. +- Ensure that you have implemented all necessary squad classes (ResearchSquad, WriteXSquad, WriteLinkedInSquad) before running. +""" + +# Common imports for both examples +from squadai import Pipeline + + + +# Uncomment the squads you need for your chosen example +from ..squads.research_squad.research_squad import ResearchSquad +from ..squads.write_x_squad.write_x_squad import WriteXSquad +# from .squads.write_linkedin_squad.write_linkedin_squad import WriteLinkedInSquad # Uncomment for Example 2 + +# EXAMPLE 1: Two-Stage Pipeline +# ----------------------------- +# Uncomment the following code block to use Example 1 + +class {{pipeline_name}}Pipeline: + def __init__(self): + # Initialize squads + self.research_squad = ResearchSquad().squad() + self.write_x_squad = WriteXSquad().squad() + + def create_pipeline(self): + return Pipeline( + stages=[ + self.research_squad, + self.write_x_squad + ] + ) + + async def kickoff(self, inputs): + pipeline = self.create_pipeline() + results = await pipeline.kickoff(inputs) + return results + + +# EXAMPLE 2: Two-Stage Pipeline with Parallel Execution +# ------------------------------------------------------- +# Uncomment the following code block to use Example 2 + +# @PipelineBase +# class {{pipeline_name}}Pipeline: +# def __init__(self): +# # Initialize squads +# self.research_squad = ResearchSquad().squad() +# self.write_x_squad = WriteXSquad().squad() +# self.write_linkedin_squad = WriteLinkedInSquad().squad() + +# @pipeline +# def create_pipeline(self): +# return Pipeline( +# stages=[ +# self.research_squad, +# [self.write_x_squad, self.write_linkedin_squad] # Parallel execution +# ] +# ) + +# async def run(self, inputs): +# pipeline = self.create_pipeline() +# results = await pipeline.kickoff(inputs) +# return results \ No newline at end of file diff --git a/squadai/cli/templates/pipeline/pyproject.toml b/squadai/cli/templates/pipeline/pyproject.toml new file mode 100644 index 0000000..c52d643 --- /dev/null +++ b/squadai/cli/templates/pipeline/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "{{folder_name}}" +version = "0.1.0" +description = "{{name}} using squadAI" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = ">=3.10,<=3.13" +squadai = { extras = ["tools"], version = ">=0.51.0,<1.0.0" } +asyncio = "*" + +[tool.poetry.scripts] +{{folder_name}} = "{{folder_name}}.main:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/squadai/cli/templates/pipeline/tools/__init__.py b/squadai/cli/templates/pipeline/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline/tools/custom_tool.py b/squadai/cli/templates/pipeline/tools/custom_tool.py new file mode 100644 index 0000000..7e6edea --- /dev/null +++ b/squadai/cli/templates/pipeline/tools/custom_tool.py @@ -0,0 +1,12 @@ +from squadai_tools import BaseTool + + +class MyCustomTool(BaseTool): + name: str = "Name of my tool" + description: str = ( + "Clear description for what this tool is useful for, you agent will need this information to use it." + ) + + def _run(self, argument: str) -> str: + # Implementation goes here + return "this is an example of a tool output, ignore it and move along." diff --git a/squadai/cli/templates/pipeline_router/.gitignore b/squadai/cli/templates/pipeline_router/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/squadai/cli/templates/pipeline_router/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/squadai/cli/templates/pipeline_router/README.md b/squadai/cli/templates/pipeline_router/README.md new file mode 100644 index 0000000..5e5a632 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/README.md @@ -0,0 +1,54 @@ +# {{squad_name}} Squad + +Welcome to the {{squad_name}} Squad project, powered by [squadAI](https://squadai.com). This template is designed to help you set up a multi-agent AI system with ease, leveraging the powerful and flexible framework provided by squadAI. Our goal is to enable your agents to collaborate effectively on complex tasks, maximizing their collective intelligence and capabilities. + +## Installation + +Ensure you have Python >=3.10 <=3.13 installed on your system. This project uses [Poetry](https://python-poetry.org/) for dependency management and package handling, offering a seamless setup and execution experience. + +First, if you haven't already, install Poetry: + +```bash +pip install poetry +``` + +Next, navigate to your project directory and install the dependencies: + +1. First lock the dependencies and then install them: +```bash +squadai install +``` +### Customizing + +**Add your `OPENAI_API_KEY` into the `.env` file** + +- Modify `src/{{folder_name}}/config/agents.yaml` to define your agents +- Modify `src/{{folder_name}}/config/tasks.yaml` to define your tasks +- Modify `src/{{folder_name}}/squad.py` to add your own logic, tools and specific args +- Modify `src/{{folder_name}}/main.py` to add custom inputs for your agents and tasks + +## Running the Project + +To kickstart your squad of AI agents and begin task execution, run this from the root folder of your project: + +```bash +squadai run +``` + +This command initializes the {{name}} Squad, assembling the agents and assigning them tasks as defined in your configuration. + +This example, unmodified, will run the create a `report.md` file with the output of a research on LLMs in the root folder. + +## Understanding Your Squad + +The {{name}} Squad is composed of multiple AI agents, each with unique roles, goals, and tools. These agents collaborate on a series of tasks, defined in `config/tasks.yaml`, leveraging their collective skills to achieve complex objectives. The `config/agents.yaml` file outlines the capabilities and configurations of each agent in your squad. + +## Support + +For support, questions, or feedback regarding the {{squad_name}} Squad or squadAI. +- Visit our [documentation](https://docs.squadai.com) +- Reach out to us through our [GitHub repository](https://github.com/joaomdmoura/squadai) +- [Join our Discord](https://discord.com/invite/X4JWnZnxPb) +- [Chat with our docs](https://chatg.pt/DWjSBZn) + +Let's create wonders together with the power and simplicity of squadAI. diff --git a/squadai/cli/templates/pipeline_router/__init__.py b/squadai/cli/templates/pipeline_router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline_router/config/agents.yaml b/squadai/cli/templates/pipeline_router/config/agents.yaml new file mode 100644 index 0000000..72ed693 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/config/agents.yaml @@ -0,0 +1,19 @@ +researcher: + role: > + {topic} Senior Data Researcher + goal: > + Uncover cutting-edge developments in {topic} + backstory: > + You're a seasoned researcher with a knack for uncovering the latest + developments in {topic}. Known for your ability to find the most relevant + information and present it in a clear and concise manner. + +reporting_analyst: + role: > + {topic} Reporting Analyst + goal: > + Create detailed reports based on {topic} data analysis and research findings + backstory: > + You're a meticulous analyst with a keen eye for detail. You're known for + your ability to turn complex data into clear and concise reports, making + it easy for others to understand and act on the information you provide. \ No newline at end of file diff --git a/squadai/cli/templates/pipeline_router/config/tasks.yaml b/squadai/cli/templates/pipeline_router/config/tasks.yaml new file mode 100644 index 0000000..f308208 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/config/tasks.yaml @@ -0,0 +1,17 @@ +research_task: + description: > + Conduct a thorough research about {topic} + Make sure you find any interesting and relevant information given + the current year is 2024. + expected_output: > + A list with 10 bullet points of the most relevant information about {topic} + agent: researcher + +reporting_task: + description: > + Review the context you got and expand each topic into a full section for a report. + Make sure the report is detailed and contains any and all relevant information. + expected_output: > + A fully fledge reports with the mains topics, each with a full section of information. + Formatted as markdown without '```' + agent: reporting_analyst diff --git a/squadai/cli/templates/pipeline_router/main.py b/squadai/cli/templates/pipeline_router/main.py new file mode 100644 index 0000000..8a4e6dd --- /dev/null +++ b/squadai/cli/templates/pipeline_router/main.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +import asyncio +from squadai.routers.router import Route +from squadai.routers.router import Router + +from {{folder_name}}.pipelines.pipeline_classifier import EmailClassifierPipeline +from {{folder_name}}.pipelines.pipeline_normal import NormalPipeline +from {{folder_name}}.pipelines.pipeline_urgent import UrgentPipeline + +async def run(): + """ + Run the pipeline. + """ + inputs = [ + { + "email": """ + Subject: URGENT: Marketing Campaign Launch - Immediate Action Required + Dear Team, + I'm reaching out regarding our upcoming marketing campaign that requires your immediate attention and swift action. We're facing a critical deadline, and our success hinges on our ability to mobilize quickly. + Key points: + + Campaign launch: 48 hours from now + Target audience: 250,000 potential customers + Expected ROI: 35% increase in Q3 sales + + What we need from you NOW: + + Final approval on creative assets (due in 3 hours) + Confirmation of media placements (due by end of day) + Last-minute budget allocation for paid social media push + + Our competitors are poised to launch similar campaigns, and we must act fast to maintain our market advantage. Delays could result in significant lost opportunities and potential revenue. + Please prioritize this campaign above all other tasks. I'll be available for the next 24 hours to address any concerns or roadblocks. + Let's make this happen! + [Your Name] + Marketing Director + P.S. I'll be scheduling an emergency team meeting in 1 hour to discuss our action plan. Attendance is mandatory. + """ + } + ] + + pipeline_classifier = EmailClassifierPipeline().create_pipeline() + pipeline_urgent = UrgentPipeline().create_pipeline() + pipeline_normal = NormalPipeline().create_pipeline() + + router = Router( + routes={ + "high_urgency": Route( + condition=lambda x: x.get("urgency_score", 0) > 7, + pipeline=pipeline_urgent + ), + "low_urgency": Route( + condition=lambda x: x.get("urgency_score", 0) <= 7, + pipeline=pipeline_normal + ) + }, + default=pipeline_normal + ) + + pipeline = pipeline_classifier >> router + + results = await pipeline.kickoff(inputs) + + # Process and print results + for result in results: + print(f"Raw output: {result.raw}") + if result.json_dict: + print(f"JSON output: {result.json_dict}") + print("\n") + +def main(): + asyncio.run(run()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/squadai/cli/templates/pipeline_router/pipelines/__init__.py b/squadai/cli/templates/pipeline_router/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline_router/pipelines/pipeline_classifier.py b/squadai/cli/templates/pipeline_router/pipelines/pipeline_classifier.py new file mode 100644 index 0000000..ac3f9e0 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/pipelines/pipeline_classifier.py @@ -0,0 +1,24 @@ +from squadai import Pipeline +from squadai.project import PipelineBase +from ..squads.classifier_squad.classifier_squad import ClassifierSquad + + +@PipelineBase +class EmailClassifierPipeline: + def __init__(self): + # Initialize squads + self.classifier_squad = ClassifierSquad().squad() + + def create_pipeline(self): + return Pipeline( + stages=[ + self.classifier_squad + ] + ) + + async def kickoff(self, inputs): + pipeline = self.create_pipeline() + results = await pipeline.kickoff(inputs) + return results + + diff --git a/squadai/cli/templates/pipeline_router/pipelines/pipeline_normal.py b/squadai/cli/templates/pipeline_router/pipelines/pipeline_normal.py new file mode 100644 index 0000000..f51f57c --- /dev/null +++ b/squadai/cli/templates/pipeline_router/pipelines/pipeline_normal.py @@ -0,0 +1,24 @@ +from squadai import Pipeline +from squadai.project import PipelineBase +from ..squads.normal_squad.normal_squad import NormalSquad + + +@PipelineBase +class NormalPipeline: + def __init__(self): + # Initialize squads + self.normal_squad = NormalSquad().squad() + + def create_pipeline(self): + return Pipeline( + stages=[ + self.normal_squad + ] + ) + + async def kickoff(self, inputs): + pipeline = self.create_pipeline() + results = await pipeline.kickoff(inputs) + return results + + diff --git a/squadai/cli/templates/pipeline_router/pipelines/pipeline_urgent.py b/squadai/cli/templates/pipeline_router/pipelines/pipeline_urgent.py new file mode 100644 index 0000000..57f8925 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/pipelines/pipeline_urgent.py @@ -0,0 +1,23 @@ +from squadai import Pipeline +from squadai.project import PipelineBase +from ..squads.urgent_squad.urgent_squad import UrgentSquad + +@PipelineBase +class UrgentPipeline: + def __init__(self): + # Initialize squads + self.urgent_squad = UrgentSquad().squad() + + def create_pipeline(self): + return Pipeline( + stages=[ + self.urgent_squad + ] + ) + + async def kickoff(self, inputs): + pipeline = self.create_pipeline() + results = await pipeline.kickoff(inputs) + return results + + diff --git a/squadai/cli/templates/pipeline_router/pyproject.toml b/squadai/cli/templates/pipeline_router/pyproject.toml new file mode 100644 index 0000000..8b83f98 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "{{folder_name}}" +version = "0.1.0" +description = "{{name}} using squadAI" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = ">=3.10,<=3.13" +squadai = { extras = ["tools"], version = ">=0.51.0,<1.0.0" } + + +[tool.poetry.scripts] +{{folder_name}} = "{{folder_name}}.main:main" +train = "{{folder_name}}.main:train" +replay = "{{folder_name}}.main:replay" +test = "{{folder_name}}.main:test" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/squadai/cli/templates/pipeline_router/squads/classifier_squad/classifier_squad.py b/squadai/cli/templates/pipeline_router/squads/classifier_squad/classifier_squad.py new file mode 100644 index 0000000..28ff304 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/classifier_squad/classifier_squad.py @@ -0,0 +1,40 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task +from pydantic import BaseModel + +# Uncomment the following line to use an example of a custom tool +# from demo_pipeline.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + +class UrgencyScore(BaseModel): + urgency_score: int + +@SquadBase +class ClassifierSquad: + """Email Classifier Squad""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def classifier(self) -> Agent: + return Agent(config=self.agents_config["classifier"], verbose=True) + + @task + def urgent_task(self) -> Task: + return Task( + config=self.tasks_config["classify_email"], + output_pydantic=UrgencyScore, + ) + + @squad + def squad(self) -> Squad: + """Creates the Email Classifier Squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) diff --git a/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/agents.yaml b/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/agents.yaml new file mode 100644 index 0000000..45506d0 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/agents.yaml @@ -0,0 +1,7 @@ +classifier: + role: > + Email Classifier + goal: > + Classify the email: {email} as urgent or normal from a score of 1 to 10, where 1 is not urgent and 10 is urgent. Return the urgency score only.` + backstory: > + You are a highly efficient and experienced email classifier, trained to quickly assess and classify emails. Your ability to remain calm under pressure and provide concise, actionable responses has made you an invaluable asset in managing normal situations and maintaining smooth operations. diff --git a/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/tasks.yaml b/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/tasks.yaml new file mode 100644 index 0000000..cd843fd --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/classifier_squad/config/tasks.yaml @@ -0,0 +1,7 @@ +classify_email: + description: > + Classify the email: {email} + as urgent or normal. + expected_output: > + Classify the email from a scale of 1 to 10, where 1 is not urgent and 10 is urgent. Return the urgency score only. + agent: classifier diff --git a/squadai/cli/templates/pipeline_router/squads/normal_squad/config/agents.yaml b/squadai/cli/templates/pipeline_router/squads/normal_squad/config/agents.yaml new file mode 100644 index 0000000..c847ce8 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/normal_squad/config/agents.yaml @@ -0,0 +1,7 @@ +normal_handler: + role: > + Normal Email Processor + goal: > + Process normal emails and create an email to respond to the sender. + backstory: > + You are a highly efficient and experienced normal email handler, trained to quickly assess and respond to normal communications. Your ability to remain calm under pressure and provide concise, actionable responses has made you an invaluable asset in managing normal situations and maintaining smooth operations. diff --git a/squadai/cli/templates/pipeline_router/squads/normal_squad/config/tasks.yaml b/squadai/cli/templates/pipeline_router/squads/normal_squad/config/tasks.yaml new file mode 100644 index 0000000..341303e --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/normal_squad/config/tasks.yaml @@ -0,0 +1,6 @@ +normal_task: + description: > + Process and respond to normal email quickly. + expected_output: > + An email response to the normal email. + agent: normal_handler diff --git a/squadai/cli/templates/pipeline_router/squads/normal_squad/normal_squad.py b/squadai/cli/templates/pipeline_router/squads/normal_squad/normal_squad.py new file mode 100644 index 0000000..5393364 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/normal_squad/normal_squad.py @@ -0,0 +1,36 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from demo_pipeline.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + + +@SquadBase +class NormalSquad: + """Normal Email Squad""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def normal_handler(self) -> Agent: + return Agent(config=self.agents_config["normal_handler"], verbose=True) + + @task + def urgent_task(self) -> Task: + return Task( + config=self.tasks_config["normal_task"], + ) + + @squad + def squad(self) -> Squad: + """Creates the Normal Email Squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) diff --git a/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/agents.yaml b/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/agents.yaml new file mode 100644 index 0000000..52804a9 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/agents.yaml @@ -0,0 +1,7 @@ +urgent_handler: + role: > + Urgent Email Processor + goal: > + Process urgent emails and create an email to respond to the sender. + backstory: > + You are a highly efficient and experienced urgent email handler, trained to quickly assess and respond to time-sensitive communications. Your ability to remain calm under pressure and provide concise, actionable responses has made you an invaluable asset in managing critical situations and maintaining smooth operations. diff --git a/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/tasks.yaml b/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/tasks.yaml new file mode 100644 index 0000000..dc2ee1c --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/urgent_squad/config/tasks.yaml @@ -0,0 +1,6 @@ +urgent_task: + description: > + Process and respond to urgent email quickly. + expected_output: > + An email response to the urgent email. + agent: urgent_handler diff --git a/squadai/cli/templates/pipeline_router/squads/urgent_squad/urgent_squad.py b/squadai/cli/templates/pipeline_router/squads/urgent_squad/urgent_squad.py new file mode 100644 index 0000000..d56bbd6 --- /dev/null +++ b/squadai/cli/templates/pipeline_router/squads/urgent_squad/urgent_squad.py @@ -0,0 +1,36 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from demo_pipeline.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + + +@SquadBase +class UrgentSquad: + """Urgent Email Squad""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def urgent_handler(self) -> Agent: + return Agent(config=self.agents_config["urgent_handler"], verbose=True) + + @task + def urgent_task(self) -> Task: + return Task( + config=self.tasks_config["urgent_task"], + ) + + @squad + def squad(self) -> Squad: + """Creates the Urgent Email Squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + ) diff --git a/squadai/cli/templates/pipeline_router/tools/__init__.py b/squadai/cli/templates/pipeline_router/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/pipeline_router/tools/custom_tool.py b/squadai/cli/templates/pipeline_router/tools/custom_tool.py new file mode 100644 index 0000000..7e6edea --- /dev/null +++ b/squadai/cli/templates/pipeline_router/tools/custom_tool.py @@ -0,0 +1,12 @@ +from squadai_tools import BaseTool + + +class MyCustomTool(BaseTool): + name: str = "Name of my tool" + description: str = ( + "Clear description for what this tool is useful for, you agent will need this information to use it." + ) + + def _run(self, argument: str) -> str: + # Implementation goes here + return "this is an example of a tool output, ignore it and move along." diff --git a/squadai/cli/templates/squad/.gitignore b/squadai/cli/templates/squad/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/squadai/cli/templates/squad/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/squadai/cli/templates/squad/README.md b/squadai/cli/templates/squad/README.md new file mode 100644 index 0000000..46aac75 --- /dev/null +++ b/squadai/cli/templates/squad/README.md @@ -0,0 +1,54 @@ +# {{squad_name}} Squad + +Welcome to the {{squad_name}} Squad project, powered by [squadAI](https://squadai.com). This template is designed to help you set up a multi-agent AI system with ease, leveraging the powerful and flexible framework provided by squadAI. Our goal is to enable your agents to collaborate effectively on complex tasks, maximizing their collective intelligence and capabilities. + +## Installation + +Ensure you have Python >=3.10 <=3.13 installed on your system. This project uses [Poetry](https://python-poetry.org/) for dependency management and package handling, offering a seamless setup and execution experience. + +First, if you haven't already, install Poetry: + +```bash +pip install poetry +``` + +Next, navigate to your project directory and install the dependencies: + +1. First lock the dependencies and install them by using the CLI command: +```bash +squadai install +``` +### Customizing + +**Add your `OPENAI_API_KEY` into the `.env` file** + +- Modify `src/{{folder_name}}/config/agents.yaml` to define your agents +- Modify `src/{{folder_name}}/config/tasks.yaml` to define your tasks +- Modify `src/{{folder_name}}/squad.py` to add your own logic, tools and specific args +- Modify `src/{{folder_name}}/main.py` to add custom inputs for your agents and tasks + +## Running the Project + +To kickstart your squad of AI agents and begin task execution, run this from the root folder of your project: + +```bash +$ squadai run +``` + +This command initializes the {{name}} Squad, assembling the agents and assigning them tasks as defined in your configuration. + +This example, unmodified, will run the create a `report.md` file with the output of a research on LLMs in the root folder. + +## Understanding Your Squad + +The {{name}} Squad is composed of multiple AI agents, each with unique roles, goals, and tools. These agents collaborate on a series of tasks, defined in `config/tasks.yaml`, leveraging their collective skills to achieve complex objectives. The `config/agents.yaml` file outlines the capabilities and configurations of each agent in your squad. + +## Support + +For support, questions, or feedback regarding the {{squad_name}} Squad or squadAI. +- Visit our [documentation](https://docs.squadai.com) +- Reach out to us through our [GitHub repository](https://github.com/joaomdmoura/squadai) +- [Join our Discord](https://discord.com/invite/X4JWnZnxPb) +- [Chat with our docs](https://chatg.pt/DWjSBZn) + +Let's create wonders together with the power and simplicity of squadAI. diff --git a/squadai/cli/templates/squad/__init__.py b/squadai/cli/templates/squad/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/squad/config/agents.yaml b/squadai/cli/templates/squad/config/agents.yaml new file mode 100644 index 0000000..72ed693 --- /dev/null +++ b/squadai/cli/templates/squad/config/agents.yaml @@ -0,0 +1,19 @@ +researcher: + role: > + {topic} Senior Data Researcher + goal: > + Uncover cutting-edge developments in {topic} + backstory: > + You're a seasoned researcher with a knack for uncovering the latest + developments in {topic}. Known for your ability to find the most relevant + information and present it in a clear and concise manner. + +reporting_analyst: + role: > + {topic} Reporting Analyst + goal: > + Create detailed reports based on {topic} data analysis and research findings + backstory: > + You're a meticulous analyst with a keen eye for detail. You're known for + your ability to turn complex data into clear and concise reports, making + it easy for others to understand and act on the information you provide. \ No newline at end of file diff --git a/squadai/cli/templates/squad/config/tasks.yaml b/squadai/cli/templates/squad/config/tasks.yaml new file mode 100644 index 0000000..f308208 --- /dev/null +++ b/squadai/cli/templates/squad/config/tasks.yaml @@ -0,0 +1,17 @@ +research_task: + description: > + Conduct a thorough research about {topic} + Make sure you find any interesting and relevant information given + the current year is 2024. + expected_output: > + A list with 10 bullet points of the most relevant information about {topic} + agent: researcher + +reporting_task: + description: > + Review the context you got and expand each topic into a full section for a report. + Make sure the report is detailed and contains any and all relevant information. + expected_output: > + A fully fledge reports with the mains topics, each with a full section of information. + Formatted as markdown without '```' + agent: reporting_analyst diff --git a/squadai/cli/templates/squad/main.py b/squadai/cli/templates/squad/main.py new file mode 100644 index 0000000..3b9c6dc --- /dev/null +++ b/squadai/cli/templates/squad/main.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +import sys +from {{folder_name}}.squad import {{squad_name}}Squad + +# This main file is intended to be a way for your to run your +# squad locally, so refrain from adding necessary logic into this file. +# Replace with inputs you want to test with, it will automatically +# interpolate any tasks and agents information + +def run(): + """ + Run the squad. + """ + inputs = { + 'topic': 'AI LLMs' + } + {{squad_name}}Squad().squad().kickoff(inputs=inputs) + + +def train(): + """ + Train the squad for a given number of iterations. + """ + inputs = { + "topic": "AI LLMs" + } + try: + {{squad_name}}Squad().squad().train(n_iterations=int(sys.argv[1]), filename=sys.argv[2], inputs=inputs) + + except Exception as e: + raise Exception(f"An error occurred while training the squad: {e}") + +def replay(): + """ + Replay the squad execution from a specific task. + """ + try: + {{squad_name}}Squad().squad().replay(task_id=sys.argv[1]) + + except Exception as e: + raise Exception(f"An error occurred while replaying the squad: {e}") + +def test(): + """ + Test the squad execution and returns the results. + """ + inputs = { + "topic": "AI LLMs" + } + try: + {{squad_name}}Squad().squad().test(n_iterations=int(sys.argv[1]), openai_model_name=sys.argv[2], inputs=inputs) + + except Exception as e: + raise Exception(f"An error occurred while replaying the squad: {e}") diff --git a/squadai/cli/templates/squad/pyproject.toml b/squadai/cli/templates/squad/pyproject.toml new file mode 100644 index 0000000..c5d0135 --- /dev/null +++ b/squadai/cli/templates/squad/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "{{folder_name}}" +version = "0.1.0" +description = "{{name}} using squadAI" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = ">=3.10,<=3.13" +squadai = { extras = ["tools"], version = ">=0.51.0,<1.0.0" } + + +[tool.poetry.scripts] +{{folder_name}} = "{{folder_name}}.main:run" +run_squad = "{{folder_name}}.main:run" +train = "{{folder_name}}.main:train" +replay = "{{folder_name}}.main:replay" +test = "{{folder_name}}.main:test" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/squadai/cli/templates/squad/squad.py b/squadai/cli/templates/squad/squad.py new file mode 100644 index 0000000..815575f --- /dev/null +++ b/squadai/cli/templates/squad/squad.py @@ -0,0 +1,53 @@ +from squadai import Agent, Squad, Process, Task +from squadai.project import SquadBase, agent, squad, task + +# Uncomment the following line to use an example of a custom tool +# from {{folder_name}}.tools.custom_tool import MyCustomTool + +# Check our tools documentations for more information on how to use them +# from squadai_tools import SerperDevTool + +@SquadBase +class {{squad_name}}Squad(): + """{{squad_name}} squad""" + agents_config = 'config/agents.yaml' + tasks_config = 'config/tasks.yaml' + + @agent + def researcher(self) -> Agent: + return Agent( + config=self.agents_config['researcher'], + # tools=[MyCustomTool()], # Example of custom tool, loaded on the beginning of file + verbose=True + ) + + @agent + def reporting_analyst(self) -> Agent: + return Agent( + config=self.agents_config['reporting_analyst'], + verbose=True + ) + + @task + def research_task(self) -> Task: + return Task( + config=self.tasks_config['research_task'], + ) + + @task + def reporting_task(self) -> Task: + return Task( + config=self.tasks_config['reporting_task'], + output_file='report.md' + ) + + @squad + def squad(self) -> Squad: + """Creates the {{squad_name}} squad""" + return Squad( + agents=self.agents, # Automatically created by the @agent decorator + tasks=self.tasks, # Automatically created by the @task decorator + process=Process.sequential, + verbose=True, + # process=Process.hierarchical, # In case you wanna use that instead https://docs.squadai.com/how-to/Hierarchical/ + ) \ No newline at end of file diff --git a/squadai/cli/templates/squad/tools/__init__.py b/squadai/cli/templates/squad/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/cli/templates/squad/tools/custom_tool.py b/squadai/cli/templates/squad/tools/custom_tool.py new file mode 100644 index 0000000..7e6edea --- /dev/null +++ b/squadai/cli/templates/squad/tools/custom_tool.py @@ -0,0 +1,12 @@ +from squadai_tools import BaseTool + + +class MyCustomTool(BaseTool): + name: str = "Name of my tool" + description: str = ( + "Clear description for what this tool is useful for, you agent will need this information to use it." + ) + + def _run(self, argument: str) -> str: + # Implementation goes here + return "this is an example of a tool output, ignore it and move along." diff --git a/squadai/cli/train_squad.py b/squadai/cli/train_squad.py new file mode 100644 index 0000000..fed8ff1 --- /dev/null +++ b/squadai/cli/train_squad.py @@ -0,0 +1,32 @@ +import subprocess + +import click + + +def train_squad(n_iterations: int, filename: str) -> None: + """ + Train the squad by running a command in the Poetry environment. + + Args: + n_iterations (int): The number of iterations to train the squad. + """ + command = ["poetry", "run", "train", str(n_iterations), filename] + + try: + if n_iterations <= 0: + raise ValueError("The number of iterations must be a positive integer.") + + if not filename.endswith(".pkl"): + raise ValueError("The filename must not end with .pkl") + + result = subprocess.run(command, capture_output=False, text=True, check=True) + + if result.stderr: + click.echo(result.stderr, err=True) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while training the squad: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/squadai/cli/utils.py b/squadai/cli/utils.py new file mode 100644 index 0000000..b811fe0 --- /dev/null +++ b/squadai/cli/utils.py @@ -0,0 +1,18 @@ +import click + + +def copy_template(src, dst, name, class_name, folder_name): + """Copy a file from src to dst.""" + with open(src, "r") as file: + content = file.read() + + # Interpolate the content + content = content.replace("{{name}}", name) + content = content.replace("{{squad_name}}", class_name) + content = content.replace("{{folder_name}}", folder_name) + + # Write the interpolated content to the new file + with open(dst, "w") as file: + file.write(content) + + click.secho(f" - Created {dst}", fg="green") diff --git a/squadai/memory/__init__.py b/squadai/memory/__init__.py new file mode 100644 index 0000000..8182bed --- /dev/null +++ b/squadai/memory/__init__.py @@ -0,0 +1,5 @@ +from .entity.entity_memory import EntityMemory +from .long_term.long_term_memory import LongTermMemory +from .short_term.short_term_memory import ShortTermMemory + +__all__ = ["EntityMemory", "LongTermMemory", "ShortTermMemory"] diff --git a/squadai/memory/contextual/__init__.py b/squadai/memory/contextual/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/memory/contextual/contextual_memory.py b/squadai/memory/contextual/contextual_memory.py new file mode 100644 index 0000000..796260f --- /dev/null +++ b/squadai/memory/contextual/contextual_memory.py @@ -0,0 +1,65 @@ +from typing import Optional + +from squadai.memory import EntityMemory, LongTermMemory, ShortTermMemory + + +class ContextualMemory: + def __init__(self, stm: ShortTermMemory, ltm: LongTermMemory, em: EntityMemory): + self.stm = stm + self.ltm = ltm + self.em = em + + def build_context_for_task(self, task, context) -> str: + """ + Automatically builds a minimal, highly relevant set of contextual information + for a given task. + """ + query = f"{task.description} {context}".strip() + + if query == "": + return "" + + context = [] + context.append(self._fetch_ltm_context(task.description)) + context.append(self._fetch_stm_context(query)) + context.append(self._fetch_entity_context(query)) + return "\n".join(filter(None, context)) + + def _fetch_stm_context(self, query) -> str: + """ + Fetches recent relevant insights from STM related to the task's description and expected_output, + formatted as bullet points. + """ + stm_results = self.stm.search(query) + formatted_results = "\n".join([f"- {result}" for result in stm_results]) + return f"Recent Insights:\n{formatted_results}" if stm_results else "" + + def _fetch_ltm_context(self, task) -> Optional[str]: + """ + Fetches historical data or insights from LTM that are relevant to the task's description and expected_output, + formatted as bullet points. + """ + ltm_results = self.ltm.search(task, latest_n=2) + if not ltm_results: + return None + + formatted_results = [ + suggestion + for result in ltm_results + for suggestion in result["metadata"]["suggestions"] # type: ignore # Invalid index type "str" for "str"; expected type "SupportsIndex | slice" + ] + formatted_results = list(dict.fromkeys(formatted_results)) + formatted_results = "\n".join([f"- {result}" for result in formatted_results]) # type: ignore # Incompatible types in assignment (expression has type "str", variable has type "list[str]") + + return f"Historical Data:\n{formatted_results}" if ltm_results else "" + + def _fetch_entity_context(self, query) -> str: + """ + Fetches relevant entity information from Entity Memory related to the task's description and expected_output, + formatted as bullet points. + """ + em_results = self.em.search(query) + formatted_results = "\n".join( + [f"- {result['context']}" for result in em_results] # type: ignore # Invalid index type "str" for "str"; expected type "SupportsIndex | slice" + ) + return f"Entities:\n{formatted_results}" if em_results else "" diff --git a/squadai/memory/entity/__init__.py b/squadai/memory/entity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/memory/entity/entity_memory.py b/squadai/memory/entity/entity_memory.py new file mode 100644 index 0000000..c3e88e8 --- /dev/null +++ b/squadai/memory/entity/entity_memory.py @@ -0,0 +1,31 @@ +from squadai.memory.entity.entity_memory_item import EntityMemoryItem +from squadai.memory.memory import Memory +from squadai.memory.storage.rag_storage import RAGStorage + + +class EntityMemory(Memory): + """ + EntityMemory class for managing structured information about entities + and their relationships using SQLite storage. + Inherits from the Memory class. + """ + + def __init__(self, squad=None, embedder_config=None): + storage = RAGStorage( + type="entities", + allow_reset=False, + embedder_config=embedder_config, + squad=squad, + ) + super().__init__(storage) + + def save(self, item: EntityMemoryItem) -> None: # type: ignore # BUG?: Signature of "save" incompatible with supertype "Memory" + """Saves an entity item into the SQLite storage.""" + data = f"{item.name}({item.type}): {item.description}" + super().save(data, item.metadata) + + def reset(self) -> None: + try: + self.storage.reset() + except Exception as e: + raise Exception(f"An error occurred while resetting the entity memory: {e}") diff --git a/squadai/memory/entity/entity_memory_item.py b/squadai/memory/entity/entity_memory_item.py new file mode 100644 index 0000000..7e1ef1c --- /dev/null +++ b/squadai/memory/entity/entity_memory_item.py @@ -0,0 +1,12 @@ +class EntityMemoryItem: + def __init__( + self, + name: str, + type: str, + description: str, + relationships: str, + ): + self.name = name + self.type = type + self.description = description + self.metadata = {"relationships": relationships} diff --git a/squadai/memory/long_term/__init__.py b/squadai/memory/long_term/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/memory/long_term/long_term_memory.py b/squadai/memory/long_term/long_term_memory.py new file mode 100644 index 0000000..5b450ee --- /dev/null +++ b/squadai/memory/long_term/long_term_memory.py @@ -0,0 +1,35 @@ +from typing import Any, Dict + +from squadai.memory.long_term.long_term_memory_item import LongTermMemoryItem +from squadai.memory.memory import Memory +from squadai.memory.storage.ltm_sqlite_storage import LTMSQLiteStorage + + +class LongTermMemory(Memory): + """ + LongTermMemory class for managing cross runs data related to overall squad's + execution and performance. + Inherits from the Memory class and utilizes an instance of a class that + adheres to the Storage for data storage, specifically working with + LongTermMemoryItem instances. + """ + + def __init__(self): + storage = LTMSQLiteStorage() + super().__init__(storage) + + def save(self, item: LongTermMemoryItem) -> None: # type: ignore # BUG?: Signature of "save" incompatible with supertype "Memory" + metadata = item.metadata + metadata.update({"agent": item.agent, "expected_output": item.expected_output}) + self.storage.save( # type: ignore # BUG?: Unexpected keyword argument "task_description","score","datetime" for "save" of "Storage" + task_description=item.task, + score=metadata["quality"], + metadata=metadata, + datetime=item.datetime, + ) + + def search(self, task: str, latest_n: int = 3) -> Dict[str, Any]: + return self.storage.load(task, latest_n) # type: ignore # BUG?: "Storage" has no attribute "load" + + def reset(self) -> None: + self.storage.reset() diff --git a/squadai/memory/long_term/long_term_memory_item.py b/squadai/memory/long_term/long_term_memory_item.py new file mode 100644 index 0000000..b2164f2 --- /dev/null +++ b/squadai/memory/long_term/long_term_memory_item.py @@ -0,0 +1,19 @@ +from typing import Any, Dict, Optional, Union + + +class LongTermMemoryItem: + def __init__( + self, + agent: str, + task: str, + expected_output: str, + datetime: str, + quality: Optional[Union[int, float]] = None, + metadata: Optional[Dict[str, Any]] = None, + ): + self.task = task + self.agent = agent + self.quality = quality + self.datetime = datetime + self.expected_output = expected_output + self.metadata = metadata if metadata is not None else {} diff --git a/squadai/memory/memory.py b/squadai/memory/memory.py new file mode 100644 index 0000000..35c0526 --- /dev/null +++ b/squadai/memory/memory.py @@ -0,0 +1,27 @@ +from typing import Any, Dict, Optional + +from squadai.memory.storage.interface import Storage + + +class Memory: + """ + Base class for memory, now supporting agent tags and generic metadata. + """ + + def __init__(self, storage: Storage): + self.storage = storage + + def save( + self, + value: Any, + metadata: Optional[Dict[str, Any]] = None, + agent: Optional[str] = None, + ) -> None: + metadata = metadata or {} + if agent: + metadata["agent"] = agent + + self.storage.save(value, metadata) + + def search(self, query: str) -> Dict[str, Any]: + return self.storage.search(query) diff --git a/squadai/memory/short_term/__init__.py b/squadai/memory/short_term/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/memory/short_term/short_term_memory.py b/squadai/memory/short_term/short_term_memory.py new file mode 100644 index 0000000..cdcc199 --- /dev/null +++ b/squadai/memory/short_term/short_term_memory.py @@ -0,0 +1,41 @@ +from typing import Any, Dict, Optional +from squadai.memory.memory import Memory +from squadai.memory.short_term.short_term_memory_item import ShortTermMemoryItem +from squadai.memory.storage.rag_storage import RAGStorage + + +class ShortTermMemory(Memory): + """ + ShortTermMemory class for managing transient data related to immediate tasks + and interactions. + Inherits from the Memory class and utilizes an instance of a class that + adheres to the Storage for data storage, specifically working with + MemoryItem instances. + """ + + def __init__(self, squad=None, embedder_config=None): + storage = RAGStorage( + type="short_term", embedder_config=embedder_config, squad=squad + ) + super().__init__(storage) + + def save( + self, + value: Any, + metadata: Optional[Dict[str, Any]] = None, + agent: Optional[str] = None, + ) -> None: + item = ShortTermMemoryItem(data=value, metadata=metadata, agent=agent) + + super().save(value=item.data, metadata=item.metadata, agent=item.agent) + + def search(self, query: str, score_threshold: float = 0.35): + return self.storage.search(query=query, score_threshold=score_threshold) # type: ignore # BUG? The reference is to the parent class, but the parent class does not have this parameters + + def reset(self) -> None: + try: + self.storage.reset() + except Exception as e: + raise Exception( + f"An error occurred while resetting the short-term memory: {e}" + ) diff --git a/squadai/memory/short_term/short_term_memory_item.py b/squadai/memory/short_term/short_term_memory_item.py new file mode 100644 index 0000000..83b7f84 --- /dev/null +++ b/squadai/memory/short_term/short_term_memory_item.py @@ -0,0 +1,13 @@ +from typing import Any, Dict, Optional + + +class ShortTermMemoryItem: + def __init__( + self, + data: Any, + agent: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ): + self.data = data + self.agent = agent + self.metadata = metadata if metadata is not None else {} diff --git a/squadai/memory/storage/interface.py b/squadai/memory/storage/interface.py new file mode 100644 index 0000000..0ffc1de --- /dev/null +++ b/squadai/memory/storage/interface.py @@ -0,0 +1,14 @@ +from typing import Any, Dict + + +class Storage: + """Abstract base class defining the storage interface""" + + def save(self, value: Any, metadata: Dict[str, Any]) -> None: + pass + + def search(self, key: str) -> Dict[str, Any]: # type: ignore + pass + + def reset(self) -> None: + pass diff --git a/squadai/memory/storage/kickoff_task_outputs_storage.py b/squadai/memory/storage/kickoff_task_outputs_storage.py new file mode 100644 index 0000000..1bedb08 --- /dev/null +++ b/squadai/memory/storage/kickoff_task_outputs_storage.py @@ -0,0 +1,166 @@ +import json +import sqlite3 +from typing import Any, Dict, List, Optional + +from squadai.task import Task +from squadai.utilities import Printer +from squadai.utilities.squad_json_encoder import SquadJSONEncoder +from squadai.utilities.paths import db_storage_path + + +class KickoffTaskOutputsSQLiteStorage: + """ + An updated SQLite storage class for kickoff task outputs storage. + """ + + def __init__( + self, db_path: str = f"{db_storage_path()}/latest_kickoff_task_outputs.db" + ) -> None: + self.db_path = db_path + self._printer: Printer = Printer() + self._initialize_db() + + def _initialize_db(self): + """ + Initializes the SQLite database and creates LTM table + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS latest_kickoff_task_outputs ( + task_id TEXT PRIMARY KEY, + expected_output TEXT, + output JSON, + task_index INTEGER, + inputs JSON, + was_replayed BOOLEAN, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.commit() + except sqlite3.Error as e: + self._printer.print( + content=f"SAVING KICKOFF TASK OUTPUTS ERROR: An error occurred during database initialization: {e}", + color="red", + ) + + def add( + self, + task: Task, + output: Dict[str, Any], + task_index: int, + was_replayed: bool = False, + inputs: Dict[str, Any] = {}, + ): + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT OR REPLACE INTO latest_kickoff_task_outputs + (task_id, expected_output, output, task_index, inputs, was_replayed) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + str(task.id), + task.expected_output, + json.dumps(output, cls=SquadJSONEncoder), + task_index, + json.dumps(inputs), + was_replayed, + ), + ) + conn.commit() + except sqlite3.Error as e: + self._printer.print( + content=f"SAVING KICKOFF TASK OUTPUTS ERROR: An error occurred during database initialization: {e}", + color="red", + ) + + def update( + self, + task_index: int, + **kwargs, + ): + """ + Updates an existing row in the latest_kickoff_task_outputs table based on task_index. + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + fields = [] + values = [] + for key, value in kwargs.items(): + fields.append(f"{key} = ?") + values.append( + json.dumps(value, cls=SquadJSONEncoder) + if isinstance(value, dict) + else value + ) + + query = f"UPDATE latest_kickoff_task_outputs SET {', '.join(fields)} WHERE task_index = ?" + values.append(task_index) + + cursor.execute(query, tuple(values)) + conn.commit() + + if cursor.rowcount == 0: + self._printer.print( + f"No row found with task_index {task_index}. No update performed.", + color="red", + ) + except sqlite3.Error as e: + self._printer.print(f"UPDATE KICKOFF TASK OUTPUTS ERROR: {e}", color="red") + + def load(self) -> Optional[List[Dict[str, Any]]]: + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * + FROM latest_kickoff_task_outputs + ORDER BY task_index + """) + + rows = cursor.fetchall() + results = [] + for row in rows: + result = { + "task_id": row[0], + "expected_output": row[1], + "output": json.loads(row[2]), + "task_index": row[3], + "inputs": json.loads(row[4]), + "was_replayed": row[5], + "timestamp": row[6], + } + results.append(result) + + return results + + except sqlite3.Error as e: + self._printer.print( + content=f"LOADING KICKOFF TASK OUTPUTS ERROR: An error occurred while querying kickoff task outputs: {e}", + color="red", + ) + return None + + def delete_all(self): + """ + Deletes all rows from the latest_kickoff_task_outputs table. + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM latest_kickoff_task_outputs") + conn.commit() + except sqlite3.Error as e: + self._printer.print( + content=f"ERROR: Failed to delete all kickoff task outputs: {e}", + color="red", + ) diff --git a/squadai/memory/storage/ltm_sqlite_storage.py b/squadai/memory/storage/ltm_sqlite_storage.py new file mode 100644 index 0000000..74aef84 --- /dev/null +++ b/squadai/memory/storage/ltm_sqlite_storage.py @@ -0,0 +1,122 @@ +import json +import sqlite3 +from typing import Any, Dict, List, Optional, Union + +from squadai.utilities import Printer +from squadai.utilities.paths import db_storage_path + + +class LTMSQLiteStorage: + """ + An updated SQLite storage class for LTM data storage. + """ + + def __init__( + self, db_path: str = f"{db_storage_path()}/long_term_memory_storage.db" + ) -> None: + self.db_path = db_path + self._printer: Printer = Printer() + self._initialize_db() + + def _initialize_db(self): + """ + Initializes the SQLite database and creates LTM table + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS long_term_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_description TEXT, + metadata TEXT, + datetime TEXT, + score REAL + ) + """ + ) + + conn.commit() + except sqlite3.Error as e: + self._printer.print( + content=f"MEMORY ERROR: An error occurred during database initialization: {e}", + color="red", + ) + + def save( + self, + task_description: str, + metadata: Dict[str, Any], + datetime: str, + score: Union[int, float], + ) -> None: + """Saves data to the LTM table with error handling.""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO long_term_memories (task_description, metadata, datetime, score) + VALUES (?, ?, ?, ?) + """, + (task_description, json.dumps(metadata), datetime, score), + ) + conn.commit() + except sqlite3.Error as e: + self._printer.print( + content=f"MEMORY ERROR: An error occurred while saving to LTM: {e}", + color="red", + ) + + def load( + self, task_description: str, latest_n: int + ) -> Optional[List[Dict[str, Any]]]: + """Queries the LTM table by task description with error handling.""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + f""" + SELECT metadata, datetime, score + FROM long_term_memories + WHERE task_description = ? + ORDER BY datetime DESC, score ASC + LIMIT {latest_n} + """, + (task_description,), + ) + rows = cursor.fetchall() + if rows: + return [ + { + "metadata": json.loads(row[0]), + "datetime": row[1], + "score": row[2], + } + for row in rows + ] + + except sqlite3.Error as e: + self._printer.print( + content=f"MEMORY ERROR: An error occurred while querying LTM: {e}", + color="red", + ) + return None + + def reset( + self, + ) -> None: + """Resets the LTM table with error handling.""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM long_term_memories") + conn.commit() + + except sqlite3.Error as e: + self._printer.print( + content=f"MEMORY ERROR: An error occurred while deleting all rows in LTM: {e}", + color="red", + ) + return None diff --git a/squadai/memory/storage/rag_storage.py b/squadai/memory/storage/rag_storage.py new file mode 100644 index 0000000..486dd45 --- /dev/null +++ b/squadai/memory/storage/rag_storage.py @@ -0,0 +1,119 @@ +import contextlib +import io +import logging +import os +import shutil +from typing import Any, Dict, List, Optional + +from embedchain import App +from embedchain.llm.base import BaseLlm +from embedchain.models.data_type import DataType +from embedchain.vectordb.chroma import InvalidDimensionException + +from squadai.memory.storage.interface import Storage +from squadai.utilities.paths import db_storage_path + + +@contextlib.contextmanager +def suppress_logging( + logger_name="chromadb.segment.impl.vector.local_persistent_hnsw", + level=logging.ERROR, +): + logger = logging.getLogger(logger_name) + original_level = logger.getEffectiveLevel() + logger.setLevel(level) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr( + io.StringIO() + ), contextlib.suppress(UserWarning): + yield + logger.setLevel(original_level) + + +class FakeLLM(BaseLlm): + pass + + +class RAGStorage(Storage): + """ + Extends Storage to handle embeddings for memory entries, improving + search efficiency. + """ + + def __init__(self, type, allow_reset=True, embedder_config=None, squad=None): + super().__init__() + if ( + not os.getenv("GROQ_API_KEY") + and not os.getenv("GROQ_BASE_URL") == "" + ): + os.environ["GROQ_API_KEY"] = "fake" + + agents = squad.agents if squad else [] + agents = [self._sanitize_role(agent.role) for agent in agents] + agents = "_".join(agents) + + config = { + "app": { + "config": {"name": type, "collect_metrics": False, "log_level": "ERROR"} + }, + "chunker": { + "chunk_size": 5000, + "chunk_overlap": 100, + "length_function": "len", + "min_chunk_size": 150, + }, + "vectordb": { + "provider": "chroma", + "config": { + "collection_name": type, + "dir": f"{db_storage_path()}/{type}/{agents}", + "allow_reset": allow_reset, + }, + }, + } + + if embedder_config: + config["embedder"] = embedder_config + self.type = type + self.app = App.from_config(config=config) + self.app.llm = FakeLLM() + if allow_reset: + self.app.reset() + + def _sanitize_role(self, role: str) -> str: + """ + Sanitizes agent roles to ensure valid directory names. + """ + return role.replace("\n", "").replace(" ", "_").replace("/", "_") + + def save(self, value: Any, metadata: Dict[str, Any]) -> None: + self._generate_embedding(value, metadata) + + def search( # type: ignore # BUG?: Signature of "search" incompatible with supertype "Storage" + self, + query: str, + limit: int = 3, + filter: Optional[dict] = None, + score_threshold: float = 0.35, + ) -> List[Any]: + with suppress_logging(): + try: + results = ( + self.app.search(query, limit, where=filter) + if filter + else self.app.search(query, limit) + ) + except InvalidDimensionException: + self.app.reset() + return [] + return [r for r in results if r["metadata"]["score"] >= score_threshold] + + def _generate_embedding(self, text: str, metadata: Dict[str, Any]) -> Any: + self.app.add(text, data_type=DataType.TEXT, metadata=metadata) + + def reset(self) -> None: + try: + shutil.rmtree(f"{db_storage_path()}/{self.type}") + except Exception as e: + raise Exception( + f"An error occurred while resetting the {self.type} memory: {e}" + ) diff --git a/squadai/pipeline/__init__.py b/squadai/pipeline/__init__.py new file mode 100644 index 0000000..a99c77a --- /dev/null +++ b/squadai/pipeline/__init__.py @@ -0,0 +1,5 @@ +from squadai.pipeline.pipeline import Pipeline +from squadai.pipeline.pipeline_kickoff_result import PipelineKickoffResult +from squadai.pipeline.pipeline_output import PipelineOutput + +__all__ = ["Pipeline", "PipelineKickoffResult", "PipelineOutput"] diff --git a/squadai/pipeline/pipeline.py b/squadai/pipeline/pipeline.py new file mode 100644 index 0000000..fb91388 --- /dev/null +++ b/squadai/pipeline/pipeline.py @@ -0,0 +1,405 @@ +import asyncio +import copy +from typing import Any, Dict, List, Tuple, Union + +from pydantic import BaseModel, Field, model_validator + +from squadai.squad import Squad +from squadai.squads.squad_output import SquadOutput +from squadai.pipeline.pipeline_kickoff_result import PipelineKickoffResult +from squadai.routers.router import Router +from squadai.types.usage_metrics import UsageMetrics + +Trace = Union[Union[str, Dict[str, Any]], List[Union[str, Dict[str, Any]]]] +PipelineStage = Union[Squad, List[Squad], Router] + +""" +Developer Notes: + +This module defines a Pipeline class that represents a sequence of operations (stages) +to process inputs. Each stage can be either sequential or parallel, and the pipeline +can process multiple kickoffs concurrently. + +Core Loop Explanation: +1. The `process_kickoffs` method processes multiple kickoffs in parallel, each going through + all pipeline stages. +2. The `process_single_kickoff` method handles the processing of a single kickouff through + all stages, updating metrics and input data along the way. +3. The `_process_stage` method determines whether a stage is sequential or parallel + and processes it accordingly. +4. The `_process_single_squad` and `_process_parallel_squads` methods handle the + execution of single and parallel squad stages. +5. The `_update_metrics_and_input` method updates usage metrics and the current input + with the outputs from a stage. +6. The `_build_pipeline_kickoff_results` method constructs the final results of the + pipeline kickoff, including traces and outputs. + +Handling Traces and Squad Outputs: +- During the processing of stages, we handle the results (traces and squad outputs) + for all stages except the last one differently from the final stage. +- For intermediate stages, the primary focus is on passing the input data between stages. + This involves merging the output dictionaries from all squads in a stage into a single + dictionary and passing it to the next stage. This merged dictionary allows for smooth + data flow between stages. +- For the final stage, in addition to passing the input data, we also need to prepare + the final outputs and traces to be returned as the overall result of the pipeline kickoff. + In this case, we do not merge the results, as each result needs to be included + separately in its own pipeline kickoff result. + +Pipeline Terminology: +- Pipeline: The overall structure that defines a sequence of operations. +- Stage: A distinct part of the pipeline, which can be either sequential or parallel. +- Kickoff: A specific execution of the pipeline for a given set of inputs, representing a single instance of processing through the pipeline. +- Branch: Parallel executions within a stage (e.g., concurrent squad operations). +- Trace: The journey of an individual input through the entire pipeline. + +Example pipeline structure: +squad1 >> squad2 >> squad3 + +This represents a pipeline with three sequential stages: +1. squad1 is the first stage, which processes the input and passes its output to squad2. +2. squad2 is the second stage, which takes the output from squad1 as its input, processes it, and passes its output to squad3. +3. squad3 is the final stage, which takes the output from squad2 as its input and produces the final output of the pipeline. + +Each input creates its own kickoff, flowing through all stages of the pipeline. +Multiple kickoffss can be processed concurrently, each following the defined pipeline structure. + +Another example pipeline structure: +squad1 >> [squad2, squad3] >> squad4 + +This represents a pipeline with three stages: +1. A sequential stage (squad1) +2. A parallel stage with two branches (squad2 and squad3 executing concurrently) +3. Another sequential stage (squad4) + +Each input creates its own kickoff, flowing through all stages of the pipeline. +Multiple kickoffs can be processed concurrently, each following the defined pipeline structure. +""" + + +class Pipeline(BaseModel): + stages: List[PipelineStage] = Field( + ..., description="List of squads representing stages to be executed in sequence" + ) + + @model_validator(mode="before") + @classmethod + def validate_stages(cls, values): + stages = values.get("stages", []) + + def check_nesting_and_type(item, depth=0): + if depth > 1: + raise ValueError("Double nesting is not allowed in pipeline stages") + if isinstance(item, list): + for sub_item in item: + check_nesting_and_type(sub_item, depth + 1) + elif not isinstance(item, (Squad, Router)): + raise ValueError( + f"Expected Squad instance, Router instance, or list of Squads, got {type(item)}" + ) + + for stage in stages: + check_nesting_and_type(stage) + return values + + async def kickoff( + self, inputs: List[Dict[str, Any]] + ) -> List[PipelineKickoffResult]: + """ + Processes multiple runs in parallel, each going through all pipeline stages. + + Args: + inputs (List[Dict[str, Any]]): List of inputs for each run. + + Returns: + List[PipelineKickoffResult]: List of results from each run. + """ + pipeline_results: List[PipelineKickoffResult] = [] + + # Process all runs in parallel + all_run_results = await asyncio.gather( + *(self.process_single_kickoff(input_data) for input_data in inputs) + ) + + # Flatten the list of lists into a single list of results + pipeline_results.extend( + result for run_result in all_run_results for result in run_result + ) + + return pipeline_results + + async def process_single_kickoff( + self, kickoff_input: Dict[str, Any] + ) -> List[PipelineKickoffResult]: + """ + Processes a single run through all pipeline stages. + + Args: + input (Dict[str, Any]): The input for the run. + + Returns: + List[PipelineKickoffResult]: The results of processing the run. + """ + initial_input = copy.deepcopy(kickoff_input) + current_input = copy.deepcopy(kickoff_input) + stages = self._copy_stages() + pipeline_usage_metrics: Dict[str, UsageMetrics] = {} + all_stage_outputs: List[List[SquadOutput]] = [] + traces: List[List[Union[str, Dict[str, Any]]]] = [[initial_input]] + + stage_index = 0 + while stage_index < len(stages): + stage = stages[stage_index] + stage_input = copy.deepcopy(current_input) + + if isinstance(stage, Router): + next_pipeline, route_taken = stage.route(stage_input) + stages = ( + stages[: stage_index + 1] + + list(next_pipeline.stages) + + stages[stage_index + 1 :] + ) + traces.append([{"route_taken": route_taken}]) + stage_index += 1 + continue + + stage_outputs, stage_trace = await self._process_stage(stage, stage_input) + + self._update_metrics_and_input( + pipeline_usage_metrics, current_input, stage, stage_outputs + ) + traces.append(stage_trace) + all_stage_outputs.append(stage_outputs) + stage_index += 1 + + return self._build_pipeline_kickoff_results( + all_stage_outputs, traces, pipeline_usage_metrics + ) + + async def _process_stage( + self, stage: PipelineStage, current_input: Dict[str, Any] + ) -> Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: + """ + Processes a single stage of the pipeline, which can be either sequential or parallel. + + Args: + stage (Union[Squad, List[Squad]]): The stage to process. + current_input (Dict[str, Any]): The input for the stage. + + Returns: + Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: The outputs and trace of the stage. + """ + if isinstance(stage, Squad): + return await self._process_single_squad(stage, current_input) + elif isinstance(stage, list) and all(isinstance(squad, Squad) for squad in stage): + return await self._process_parallel_squads(stage, current_input) + else: + raise ValueError(f"Unsupported stage type: {type(stage)}") + + async def _process_single_squad( + self, squad: Squad, current_input: Dict[str, Any] + ) -> Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: + """ + Processes a single squad. + + Args: + squad (Squad): The squad to process. + current_input (Dict[str, Any]): The input for the squad. + + Returns: + Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: The output and trace of the squad. + """ + output = await squad.kickoff_async(inputs=current_input) + return [output], [squad.name or str(squad.id)] + + async def _process_parallel_squads( + self, squads: List[Squad], current_input: Dict[str, Any] + ) -> Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: + """ + Processes multiple squads in parallel. + + Args: + squads (List[Squad]): The list of squads to process in parallel. + current_input (Dict[str, Any]): The input for the squads. + + Returns: + Tuple[List[SquadOutput], List[Union[str, Dict[str, Any]]]]: The outputs and traces of the squads. + """ + parallel_outputs = await asyncio.gather( + *[squad.kickoff_async(inputs=current_input) for squad in squads] + ) + return parallel_outputs, [squad.name or str(squad.id) for squad in squads] + + def _update_metrics_and_input( + self, + usage_metrics: Dict[str, UsageMetrics], + current_input: Dict[str, Any], + stage: PipelineStage, + outputs: List[SquadOutput], + ) -> None: + """ + Updates metrics and current input with the outputs of a stage. + + Args: + usage_metrics (Dict[str, Any]): The usage metrics to update. + current_input (Dict[str, Any]): The current input to update. + stage (Union[Squad, List[Squad]]): The stage that was processed. + outputs (List[SquadOutput]): The outputs of the stage. + """ + if isinstance(stage, Squad): + usage_metrics[stage.name or str(stage.id)] = outputs[0].token_usage + current_input.update(outputs[0].to_dict()) + elif isinstance(stage, list) and all(isinstance(squad, Squad) for squad in stage): + for squad, output in zip(stage, outputs): + usage_metrics[squad.name or str(squad.id)] = output.token_usage + current_input.update(output.to_dict()) + else: + raise ValueError(f"Unsupported stage type: {type(stage)}") + + def _build_pipeline_kickoff_results( + self, + all_stage_outputs: List[List[SquadOutput]], + traces: List[List[Union[str, Dict[str, Any]]]], + token_usage: Dict[str, UsageMetrics], + ) -> List[PipelineKickoffResult]: + """ + Builds the results of a pipeline run. + + Args: + all_stage_outputs (List[List[SquadOutput]]): All stage outputs. + traces (List[List[Union[str, Dict[str, Any]]]]): All traces. + token_usage (Dict[str, Any]): Token usage metrics. + + Returns: + List[PipelineKickoffResult]: The results of the pipeline run. + """ + formatted_traces = self._format_traces(traces) + formatted_squad_outputs = self._format_squad_outputs(all_stage_outputs) + + return [ + PipelineKickoffResult( + token_usage=token_usage, + trace=formatted_trace, + raw=squads_outputs[-1].raw, + pydantic=squads_outputs[-1].pydantic, + json_dict=squads_outputs[-1].json_dict, + squads_outputs=squads_outputs, + ) + for squads_outputs, formatted_trace in zip( + formatted_squad_outputs, formatted_traces + ) + ] + + def _format_traces( + self, traces: List[List[Union[str, Dict[str, Any]]]] + ) -> List[List[Trace]]: + """ + Formats the traces of a pipeline run. + + Args: + traces (List[List[Union[str, Dict[str, Any]]]]): The traces to format. + + Returns: + List[List[Trace]]: The formatted traces. + """ + formatted_traces: List[Trace] = self._format_single_trace(traces[:-1]) + return self._format_multiple_traces(formatted_traces, traces[-1]) + + def _format_single_trace( + self, traces: List[List[Union[str, Dict[str, Any]]]] + ) -> List[Trace]: + """ + Formats single traces. + + Args: + traces (List[List[Union[str, Dict[str, Any]]]]): The traces to format. + + Returns: + List[Trace]: The formatted single traces. + """ + formatted_traces: List[Trace] = [] + for trace in traces: + formatted_traces.append(trace[0] if len(trace) == 1 else trace) + return formatted_traces + + def _format_multiple_traces( + self, + formatted_traces: List[Trace], + final_trace: List[Union[str, Dict[str, Any]]], + ) -> List[List[Trace]]: + """ + Formats multiple traces. + + Args: + formatted_traces (List[Trace]): The formatted single traces. + final_trace (List[Union[str, Dict[str, Any]]]): The final trace to format. + + Returns: + List[List[Trace]]: The formatted multiple traces. + """ + traces_to_return: List[List[Trace]] = [] + if len(final_trace) == 1: + formatted_traces.append(final_trace[0]) + traces_to_return.append(formatted_traces) + else: + for trace in final_trace: + copied_traces = formatted_traces.copy() + copied_traces.append(trace) + traces_to_return.append(copied_traces) + return traces_to_return + + def _format_squad_outputs( + self, all_stage_outputs: List[List[SquadOutput]] + ) -> List[List[SquadOutput]]: + """ + Formats the outputs of all stages into a list of squad outputs. + + Args: + all_stage_outputs (List[List[SquadOutput]]): All stage outputs. + + Returns: + List[List[SquadOutput]]: Formatted squad outputs. + """ + squad_outputs: List[SquadOutput] = [ + output + for stage_outputs in all_stage_outputs[:-1] + for output in stage_outputs + ] + return [squad_outputs + [output] for output in all_stage_outputs[-1]] + + def _copy_stages(self): + """Create a deep copy of the Pipeline's stages.""" + new_stages = [] + for stage in self.stages: + if isinstance(stage, list): + new_stages.append( + [ + squad.copy() if hasattr(squad, "copy") else copy.deepcopy(squad) + for squad in stage + ] + ) + elif hasattr(stage, "copy"): + new_stages.append(stage.copy()) + else: + new_stages.append(copy.deepcopy(stage)) + + return new_stages + + def __rshift__(self, other: PipelineStage) -> "Pipeline": + """ + Implements the >> operator to add another Stage (Squad or List[Squad]) to an existing Pipeline. + + Args: + other (Any): The stage to add. + + Returns: + Pipeline: A new pipeline with the added stage. + """ + if isinstance(other, (Squad, Router)) or ( + isinstance(other, list) and all(isinstance(item, Squad) for item in other) + ): + return type(self)(stages=self.stages + [other]) + else: + raise TypeError( + f"Unsupported operand type for >>: '{type(self).__name__}' and '{type(other).__name__}'" + ) diff --git a/squadai/pipeline/pipeline_kickoff_result.py b/squadai/pipeline/pipeline_kickoff_result.py new file mode 100644 index 0000000..2198fad --- /dev/null +++ b/squadai/pipeline/pipeline_kickoff_result.py @@ -0,0 +1,61 @@ +import json +import uuid +from typing import Any, Dict, List, Optional, Union + +from pydantic import UUID4, BaseModel, Field + +from squadai.squads.squad_output import SquadOutput +from squadai.types.usage_metrics import UsageMetrics + + +class PipelineKickoffResult(BaseModel): + """Class that represents the result of a pipeline run.""" + + id: UUID4 = Field( + default_factory=uuid.uuid4, + frozen=True, + description="Unique identifier for the object, not set by user.", + ) + raw: str = Field(description="Raw output of the pipeline run", default="") + pydantic: Any = Field( + description="Pydantic output of the pipeline run", default=None + ) + json_dict: Union[Dict[str, Any], None] = Field( + description="JSON dict output of the pipeline run", default={} + ) + + token_usage: Dict[str, UsageMetrics] = Field( + description="Token usage for each squad in the run" + ) + trace: List[Any] = Field( + description="Trace of the journey of inputs through the run" + ) + squads_outputs: List[SquadOutput] = Field( + description="Output from each squad in the run", + default=[], + ) + + @property + def json(self) -> Optional[str]: + if self.squads_outputs[-1].tasks_output[-1].output_format != "json": + raise ValueError( + "No JSON output found in the final task of the final squad. Please make sure to set the output_json property in the final task in your squad." + ) + + return json.dumps(self.json_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert json_output and pydantic_output to a dictionary.""" + output_dict = {} + if self.json_dict: + output_dict.update(self.json_dict) + elif self.pydantic: + output_dict.update(self.pydantic.model_dump()) + return output_dict + + def __str__(self): + if self.pydantic: + return str(self.pydantic) + if self.json_dict: + return str(self.json_dict) + return self.raw diff --git a/squadai/pipeline/pipeline_output.py b/squadai/pipeline/pipeline_output.py new file mode 100644 index 0000000..a212f70 --- /dev/null +++ b/squadai/pipeline/pipeline_output.py @@ -0,0 +1,20 @@ +import uuid +from typing import List + +from pydantic import UUID4, BaseModel, Field + +from squadai.pipeline.pipeline_kickoff_result import PipelineKickoffResult + + +class PipelineOutput(BaseModel): + id: UUID4 = Field( + default_factory=uuid.uuid4, + frozen=True, + description="Unique identifier for the object, not set by user.", + ) + run_results: List[PipelineKickoffResult] = Field( + description="List of results for each run through the pipeline", default=[] + ) + + def add_run_result(self, result: PipelineKickoffResult): + self.run_results.append(result) diff --git a/squadai/process.py b/squadai/process.py new file mode 100644 index 0000000..2311c0e --- /dev/null +++ b/squadai/process.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class Process(str, Enum): + """ + Class representing the different processes that can be used to tackle tasks + """ + + sequential = "sequential" + hierarchical = "hierarchical" + # TODO: consensual = 'consensual' diff --git a/squadai/project/__init__.py b/squadai/project/__init__.py new file mode 100644 index 0000000..a2bab56 --- /dev/null +++ b/squadai/project/__init__.py @@ -0,0 +1,29 @@ +from .annotations import ( + agent, + cache_handler, + callback, + squad, + llm, + output_json, + output_pydantic, + pipeline, + task, + tool, +) +from .squad_base import SquadBase +from .pipeline_base import PipelineBase + +__all__ = [ + "agent", + "squad", + "task", + "output_json", + "output_pydantic", + "tool", + "callback", + "SquadBase", + "PipelineBase", + "llm", + "cache_handler", + "pipeline", +] diff --git a/squadai/project/annotations.py b/squadai/project/annotations.py new file mode 100644 index 0000000..c74b320 --- /dev/null +++ b/squadai/project/annotations.py @@ -0,0 +1,124 @@ +from functools import wraps + +from squadai.project.utils import memoize + + +def task(func): + if not hasattr(task, "registration_order"): + task.registration_order = [] + + @wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + if not result.name: + result.name = func.__name__ + return result + + setattr(wrapper, "is_task", True) + task.registration_order.append(func.__name__) + + return memoize(wrapper) + + +def agent(func): + func.is_agent = True + func = memoize(func) + return func + + +def llm(func): + func.is_llm = True + func = memoize(func) + return func + + +def output_json(cls): + cls.is_output_json = True + return cls + + +def output_pydantic(cls): + cls.is_output_pydantic = True + return cls + + +def tool(func): + func.is_tool = True + return memoize(func) + + +def callback(func): + func.is_callback = True + return memoize(func) + + +def cache_handler(func): + func.is_cache_handler = True + return memoize(func) + + +def stage(func): + func.is_stage = True + return memoize(func) + + +def router(func): + func.is_router = True + return memoize(func) + + +def pipeline(func): + func.is_pipeline = True + return memoize(func) + + +def squad(func): + def wrapper(self, *args, **kwargs): + instantiated_tasks = [] + instantiated_agents = [] + + agent_roles = set() + all_functions = { + name: getattr(self, name) + for name in dir(self) + if callable(getattr(self, name)) + } + tasks = { + name: func + for name, func in all_functions.items() + if hasattr(func, "is_task") + } + agents = { + name: func + for name, func in all_functions.items() + if hasattr(func, "is_agent") + } + + # Sort tasks by their registration order + sorted_task_names = sorted( + tasks, key=lambda name: task.registration_order.index(name) + ) + + # Instantiate tasks in the order they were defined + for task_name in sorted_task_names: + task_instance = tasks[task_name]() + instantiated_tasks.append(task_instance) + if hasattr(task_instance, "agent"): + agent_instance = task_instance.agent + if agent_instance.role not in agent_roles: + instantiated_agents.append(agent_instance) + agent_roles.add(agent_instance.role) + + # Instantiate any additional agents not already included by tasks + for agent_name in agents: + temp_agent_instance = agents[agent_name]() + if temp_agent_instance.role not in agent_roles: + instantiated_agents.append(temp_agent_instance) + agent_roles.add(temp_agent_instance.role) + + self.agents = instantiated_agents + self.tasks = instantiated_tasks + + return func(self, *args, **kwargs) + + return wrapper diff --git a/squadai/project/pipeline_base.py b/squadai/project/pipeline_base.py new file mode 100644 index 0000000..7e86310 --- /dev/null +++ b/squadai/project/pipeline_base.py @@ -0,0 +1,58 @@ +from typing import Any, Callable, Dict, List, Type, Union + +from squadai.squad import Squad +from squadai.pipeline.pipeline import Pipeline +from squadai.routers.router import Router + +PipelineStage = Union[Squad, List[Squad], Router] + + +# TODO: Could potentially remove. +def PipelineBase(cls: Type[Any]) -> Type[Any]: + class WrappedClass(cls): + is_pipeline_class: bool = True # type: ignore + stages: List[PipelineStage] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.stages = [] + self._map_pipeline_components() + + def _get_all_functions(self) -> Dict[str, Callable[..., Any]]: + return { + name: getattr(self, name) + for name in dir(self) + if callable(getattr(self, name)) + } + + def _filter_functions( + self, functions: Dict[str, Callable[..., Any]], attribute: str + ) -> Dict[str, Callable[..., Any]]: + return { + name: func + for name, func in functions.items() + if hasattr(func, attribute) + } + + def _map_pipeline_components(self) -> None: + all_functions = self._get_all_functions() + squad_functions = self._filter_functions(all_functions, "is_squad") + router_functions = self._filter_functions(all_functions, "is_router") + + for stage_attr in dir(self): + stage = getattr(self, stage_attr) + if isinstance(stage, (Squad, Router)): + self.stages.append(stage) + elif callable(stage) and hasattr(stage, "is_squad"): + self.stages.append(squad_functions[stage_attr]()) + elif callable(stage) and hasattr(stage, "is_router"): + self.stages.append(router_functions[stage_attr]()) + elif isinstance(stage, list) and all( + isinstance(item, Squad) for item in stage + ): + self.stages.append(stage) + + def build_pipeline(self) -> Pipeline: + return Pipeline(stages=self.stages) + + return WrappedClass diff --git a/squadai/project/squad_base.py b/squadai/project/squad_base.py new file mode 100644 index 0000000..3c75d01 --- /dev/null +++ b/squadai/project/squad_base.py @@ -0,0 +1,178 @@ +import inspect +from pathlib import Path +from typing import Any, Callable, Dict + +import yaml +from dotenv import load_dotenv + +load_dotenv() + + +def SquadBase(cls): + class WrappedClass(cls): + is_squad_class: bool = True # type: ignore + + # Get the directory of the class being decorated + base_directory = Path(inspect.getfile(cls)).parent + + original_agents_config_path = getattr( + cls, "agents_config", "config/agents.yaml" + ) + original_tasks_config_path = getattr(cls, "tasks_config", "config/tasks.yaml") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + agents_config_path = self.base_directory / self.original_agents_config_path + tasks_config_path = self.base_directory / self.original_tasks_config_path + + self.agents_config = self.load_yaml(agents_config_path) + self.tasks_config = self.load_yaml(tasks_config_path) + + self.map_all_agent_variables() + self.map_all_task_variables() + + @staticmethod + def load_yaml(config_path: Path): + try: + with open(config_path, "r") as file: + return yaml.safe_load(file) + except FileNotFoundError: + print(f"File not found: {config_path}") + raise + + def _get_all_functions(self): + return { + name: getattr(self, name) + for name in dir(self) + if callable(getattr(self, name)) + } + + def _filter_functions( + self, functions: Dict[str, Callable], attribute: str + ) -> Dict[str, Callable]: + return { + name: func + for name, func in functions.items() + if hasattr(func, attribute) + } + + def map_all_agent_variables(self) -> None: + all_functions = self._get_all_functions() + llms = self._filter_functions(all_functions, "is_llm") + tool_functions = self._filter_functions(all_functions, "is_tool") + cache_handler_functions = self._filter_functions( + all_functions, "is_cache_handler" + ) + callbacks = self._filter_functions(all_functions, "is_callback") + agents = self._filter_functions(all_functions, "is_agent") + + for agent_name, agent_info in self.agents_config.items(): + self._map_agent_variables( + agent_name, + agent_info, + agents, + llms, + tool_functions, + cache_handler_functions, + callbacks, + ) + + def _map_agent_variables( + self, + agent_name: str, + agent_info: Dict[str, Any], + agents: Dict[str, Callable], + llms: Dict[str, Callable], + tool_functions: Dict[str, Callable], + cache_handler_functions: Dict[str, Callable], + callbacks: Dict[str, Callable], + ) -> None: + if llm := agent_info.get("llm"): + self.agents_config[agent_name]["llm"] = llms[llm]() + + if tools := agent_info.get("tools"): + self.agents_config[agent_name]["tools"] = [ + tool_functions[tool]() for tool in tools + ] + + if function_calling_llm := agent_info.get("function_calling_llm"): + self.agents_config[agent_name]["function_calling_llm"] = agents[ + function_calling_llm + ]() + + if step_callback := agent_info.get("step_callback"): + self.agents_config[agent_name]["step_callback"] = callbacks[ + step_callback + ]() + + if cache_handler := agent_info.get("cache_handler"): + self.agents_config[agent_name]["cache_handler"] = ( + cache_handler_functions[cache_handler]() + ) + + def map_all_task_variables(self) -> None: + all_functions = self._get_all_functions() + agents = self._filter_functions(all_functions, "is_agent") + tasks = self._filter_functions(all_functions, "is_task") + output_json_functions = self._filter_functions( + all_functions, "is_output_json" + ) + tool_functions = self._filter_functions(all_functions, "is_tool") + callback_functions = self._filter_functions(all_functions, "is_callback") + output_pydantic_functions = self._filter_functions( + all_functions, "is_output_pydantic" + ) + + for task_name, task_info in self.tasks_config.items(): + self._map_task_variables( + task_name, + task_info, + agents, + tasks, + output_json_functions, + tool_functions, + callback_functions, + output_pydantic_functions, + ) + + def _map_task_variables( + self, + task_name: str, + task_info: Dict[str, Any], + agents: Dict[str, Callable], + tasks: Dict[str, Callable], + output_json_functions: Dict[str, Callable], + tool_functions: Dict[str, Callable], + callback_functions: Dict[str, Callable], + output_pydantic_functions: Dict[str, Callable], + ) -> None: + if context_list := task_info.get("context"): + self.tasks_config[task_name]["context"] = [ + tasks[context_task_name]() for context_task_name in context_list + ] + + if tools := task_info.get("tools"): + self.tasks_config[task_name]["tools"] = [ + tool_functions[tool]() for tool in tools + ] + + if agent_name := task_info.get("agent"): + self.tasks_config[task_name]["agent"] = agents[agent_name]() + + if output_json := task_info.get("output_json"): + self.tasks_config[task_name]["output_json"] = output_json_functions[ + output_json + ] + + if output_pydantic := task_info.get("output_pydantic"): + self.tasks_config[task_name]["output_pydantic"] = ( + output_pydantic_functions[output_pydantic] + ) + + if callbacks := task_info.get("callbacks"): + self.tasks_config[task_name]["callbacks"] = [ + callback_functions[callback]() for callback in callbacks + ] + + return WrappedClass diff --git a/squadai/project/utils.py b/squadai/project/utils.py new file mode 100644 index 0000000..be3f757 --- /dev/null +++ b/squadai/project/utils.py @@ -0,0 +1,11 @@ +def memoize(func): + cache = {} + + def memoized_func(*args, **kwargs): + key = (args, tuple(kwargs.items())) + if key not in cache: + cache[key] = func(*args, **kwargs) + return cache[key] + + memoized_func.__dict__.update(func.__dict__) + return memoized_func diff --git a/squadai/routers/__init__.py b/squadai/routers/__init__.py new file mode 100644 index 0000000..f3fb521 --- /dev/null +++ b/squadai/routers/__init__.py @@ -0,0 +1,3 @@ +from squadai.routers.router import Router + +__all__ = ["Router"] diff --git a/squadai/routers/router.py b/squadai/routers/router.py new file mode 100644 index 0000000..e85ce22 --- /dev/null +++ b/squadai/routers/router.py @@ -0,0 +1,84 @@ +from copy import deepcopy +from typing import Any, Callable, Dict, Tuple + +from pydantic import BaseModel, Field, PrivateAttr + + +class Route(BaseModel): + condition: Callable[[Dict[str, Any]], bool] + pipeline: Any + + +class Router(BaseModel): + routes: Dict[str, Route] = Field( + default_factory=dict, + description="Dictionary of route names to (condition, pipeline) tuples", + ) + default: Any = Field(..., description="Default pipeline if no conditions are met") + _route_types: Dict[str, type] = PrivateAttr(default_factory=dict) + + class Config: + arbitrary_types_allowed = True + + def __init__(self, routes: Dict[str, Route], default: Any, **data): + super().__init__(routes=routes, default=default, **data) + self._check_copyable(default) + for name, route in routes.items(): + self._check_copyable(route.pipeline) + self._route_types[name] = type(route.pipeline) + + @staticmethod + def _check_copyable(obj: Any) -> None: + if not hasattr(obj, "copy") or not callable(getattr(obj, "copy")): + raise ValueError(f"Object of type {type(obj)} must have a 'copy' method") + + def add_route( + self, + name: str, + condition: Callable[[Dict[str, Any]], bool], + pipeline: Any, + ) -> "Router": + """ + Add a named route with its condition and corresponding pipeline to the router. + + Args: + name: A unique name for this route + condition: A function that takes a dictionary input and returns a boolean + pipeline: The Pipeline to execute if the condition is met + + Returns: + The Router instance for method chaining + """ + self._check_copyable(pipeline) + self.routes[name] = Route(condition=condition, pipeline=pipeline) + self._route_types[name] = type(pipeline) + return self + + def route(self, input_data: Dict[str, Any]) -> Tuple[Any, str]: + """ + Evaluate the input against the conditions and return the appropriate pipeline. + + Args: + input_data: The input dictionary to be evaluated + + Returns: + A tuple containing the next Pipeline to be executed and the name of the route taken + """ + for name, route in self.routes.items(): + if route.condition(input_data): + return route.pipeline, name + + return self.default, "default" + + def copy(self) -> "Router": + """Create a deep copy of the Router.""" + new_routes = { + name: Route( + condition=deepcopy(route.condition), + pipeline=route.pipeline.copy(), + ) + for name, route in self.routes.items() + } + new_default = self.default.copy() + + return Router(routes=new_routes, default=new_default) diff --git a/squadai/squad.py b/squadai/squad.py new file mode 100644 index 0000000..fd86de7 --- /dev/null +++ b/squadai/squad.py @@ -0,0 +1,943 @@ +import asyncio +import json +import os +import uuid +from concurrent.futures import Future +from hashlib import md5 +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +from langchain_core.callbacks import BaseCallbackHandler +from pydantic import ( + UUID4, + BaseModel, + Field, + InstanceOf, + Json, + PrivateAttr, + field_validator, + model_validator, +) +from pydantic_core import PydanticCustomError + +from squadai.agent import Agent +from squadai.agents.agent_builder.base_agent import BaseAgent +from squadai.agents.cache import CacheHandler +from squadai.squads.squad_output import SquadOutput +from squadai.memory.entity.entity_memory import EntityMemory +from squadai.memory.long_term.long_term_memory import LongTermMemory +from squadai.memory.short_term.short_term_memory import ShortTermMemory +from squadai.process import Process +from squadai.task import Task +from squadai.tasks.conditional_task import ConditionalTask +from squadai.tasks.task_output import TaskOutput +from squadai.tools.agent_tools import AgentTools +from squadai.types.usage_metrics import UsageMetrics +from squadai.utilities import I18N, FileHandler, Logger, RPMController +from squadai.utilities.constants import ( + TRAINING_DATA_FILE, +) +from squadai.utilities.evaluators.squad_evaluator_handler import SquadEvaluator +from squadai.utilities.evaluators.task_evaluator import TaskEvaluator +from squadai.utilities.formatter import ( + aggregate_raw_outputs_from_task_outputs, + aggregate_raw_outputs_from_tasks, +) +from squadai.utilities.planning_handler import SquadPlanner +from squadai.utilities.task_output_storage_handler import TaskOutputStorageHandler +from squadai.utilities.training_handler import SquadTrainingHandler + + +if TYPE_CHECKING: + from squadai.pipeline.pipeline import Pipeline + + +class Squad(BaseModel): + """ + Represents a group of agents, defining how they should collaborate and the tasks they should perform. + + Attributes: + tasks: List of tasks assigned to the squad. + agents: List of agents part of this squad. + manager_llm: The language model that will run manager agent. + manager_agent: Custom agent that will be used as manager. + memory: Whether the squad should use memory to store memories of it's execution. + manager_callbacks: The callback handlers to be executed by the manager agent when hierarchical process is used + cache: Whether the squad should use a cache to store the results of the tools execution. + function_calling_llm: The language model that will run the tool calling for all the agents. + process: The process flow that the squad will follow (e.g., sequential, hierarchical). + verbose: Indicates the verbosity level for logging during execution. + config: Configuration settings for the squad. + max_rpm: Maximum number of requests per minute for the squad execution to be respected. + prompt_file: Path to the prompt json file to be used for the squad. + id: A unique identifier for the squad instance. + task_callback: Callback to be executed after each task for every agents execution. + step_callback: Callback to be executed after each step for every agents execution. + planning: Plan the squad execution and add the plan to the squad. + """ + + __hash__ = object.__hash__ # type: ignore + _execution_span: Any = PrivateAttr() + _rpm_controller: RPMController = PrivateAttr() + _logger: Logger = PrivateAttr() + _file_handler: FileHandler = PrivateAttr() + _cache_handler: InstanceOf[CacheHandler] = PrivateAttr(default=CacheHandler()) + _short_term_memory: Optional[InstanceOf[ShortTermMemory]] = PrivateAttr() + _long_term_memory: Optional[InstanceOf[LongTermMemory]] = PrivateAttr() + _entity_memory: Optional[InstanceOf[EntityMemory]] = PrivateAttr() + _train: Optional[bool] = PrivateAttr(default=False) + _train_iteration: Optional[int] = PrivateAttr() + _inputs: Optional[Dict[str, Any]] = PrivateAttr(default=None) + _logging_color: str = PrivateAttr( + default="bold_purple", + ) + _task_output_handler: TaskOutputStorageHandler = PrivateAttr( + default_factory=TaskOutputStorageHandler + ) + + name: Optional[str] = Field(default=None) + cache: bool = Field(default=True) + tasks: List[Task] = Field(default_factory=list) + agents: List[BaseAgent] = Field(default_factory=list) + process: Process = Field(default=Process.sequential) + verbose: bool = Field(default=False) + memory: bool = Field( + default=False, + description="Whether the squad should use memory to store memories of it's execution", + ) + embedder: Optional[dict] = Field( + default={"provider": "cohere"}, + description="Configuration for the embedder to be used for the squad.", + ) + usage_metrics: Optional[UsageMetrics] = Field( + default=None, + description="Metrics for the LLM usage during all tasks execution.", + ) + manager_llm: Optional[Any] = Field( + description="Language model that will run the agent.", default=None + ) + manager_agent: Optional[BaseAgent] = Field( + description="Custom agent that will be used as manager.", default=None + ) + manager_callbacks: Optional[List[InstanceOf[BaseCallbackHandler]]] = Field( + default=None, + description="A list of callback handlers to be executed by the manager agent when hierarchical process is used", + ) + function_calling_llm: Optional[Any] = Field( + description="Language model that will run the agent.", default=None + ) + config: Optional[Union[Json, Dict[str, Any]]] = Field(default=None) + id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True) + share_squad: Optional[bool] = Field(default=False) + step_callback: Optional[Any] = Field( + default=None, + description="Callback to be executed after each step for all agents execution.", + ) + task_callback: Optional[Any] = Field( + default=None, + description="Callback to be executed after each task for all agents execution.", + ) + max_rpm: Optional[int] = Field( + default=None, + description="Maximum number of requests per minute for the squad execution to be respected.", + ) + prompt_file: str = Field( + default=None, + description="Path to the prompt json file to be used for the squad.", + ) + output_log_file: Optional[str] = Field( + default=None, + description="output_log_file", + ) + planning: Optional[bool] = Field( + default=False, + description="Plan the squad execution and add the plan to the squad.", + ) + planning_llm: Optional[Any] = Field( + default=None, + description="Language model that will run the AgentPlanner if planning is True.", + ) + task_execution_output_json_files: Optional[List[str]] = Field( + default=None, + description="List of file paths for task execution JSON files.", + ) + execution_logs: List[Dict[str, Any]] = Field( + default=[], + description="List of execution logs for tasks", + ) + + @field_validator("id", mode="before") + @classmethod + def _deny_user_set_id(cls, v: Optional[UUID4]) -> None: + """Prevent manual setting of the 'id' field by users.""" + if v: + raise PydanticCustomError( + "may_not_set_field", "The 'id' field cannot be set by the user.", {} + ) + + @field_validator("config", mode="before") + @classmethod + def check_config_type( + cls, v: Union[Json, Dict[str, Any]] + ) -> Union[Json, Dict[str, Any]]: + """Validates that the config is a valid type. + Args: + v: The config to be validated. + Returns: + The config if it is valid. + """ + + # TODO: Improve typing + return json.loads(v) if isinstance(v, Json) else v # type: ignore + + @model_validator(mode="after") + def set_private_attrs(self) -> "Squad": + """Set private attributes.""" + self._cache_handler = CacheHandler() + self._logger = Logger(verbose=self.verbose) + if self.output_log_file: + self._file_handler = FileHandler(self.output_log_file) + self._rpm_controller = RPMController(max_rpm=self.max_rpm, logger=self._logger) + return self + + @model_validator(mode="after") + def create_squad_memory(self) -> "Squad": + """Set private attributes.""" + if self.memory: + self._long_term_memory = LongTermMemory() + self._short_term_memory = ShortTermMemory( + squad=self, embedder_config=self.embedder + ) + self._entity_memory = EntityMemory(squad=self, embedder_config=self.embedder) + return self + + @model_validator(mode="after") + def check_manager_llm(self): + """Validates that the language model is set when using hierarchical process.""" + if self.process == Process.hierarchical: + if not self.manager_llm and not self.manager_agent: + raise PydanticCustomError( + "missing_manager_llm_or_manager_agent", + "Attribute `manager_llm` or `manager_agent` is required when using hierarchical process.", + {}, + ) + + if (self.manager_agent is not None) and ( + self.agents.count(self.manager_agent) > 0 + ): + raise PydanticCustomError( + "manager_agent_in_agents", + "Manager agent should not be included in agents list.", + {}, + ) + + return self + + @model_validator(mode="after") + def check_config(self): + """Validates that the squad is properly configured with agents and tasks.""" + if not self.config and not self.tasks and not self.agents: + raise PydanticCustomError( + "missing_keys", + "Either 'agents' and 'tasks' need to be set or 'config'.", + {}, + ) + + if self.config: + self._setup_from_config() + + if self.agents: + for agent in self.agents: + if self.cache: + agent.set_cache_handler(self._cache_handler) + if self.max_rpm: + agent.set_rpm_controller(self._rpm_controller) + return self + + @model_validator(mode="after") + def validate_tasks(self): + if self.process == Process.sequential: + for task in self.tasks: + if task.agent is None: + raise PydanticCustomError( + "missing_agent_in_task", + f"Sequential process error: Agent is missing in the task with the following description: {task.description}", # type: ignore # Argument of type "str" cannot be assigned to parameter "message_template" of type "LiteralString" + {}, + ) + + return self + + @model_validator(mode="after") + def validate_end_with_at_most_one_async_task(self): + """Validates that the squad ends with at most one asynchronous task.""" + final_async_task_count = 0 + + # Traverse tasks backward + for task in reversed(self.tasks): + if task.async_execution: + final_async_task_count += 1 + else: + break # Stop traversing as soon as a non-async task is encountered + + if final_async_task_count > 1: + raise PydanticCustomError( + "async_task_count", + "The squad must end with at most one asynchronous task.", + {}, + ) + + return self + + @model_validator(mode="after") + def validate_first_task(self) -> "Squad": + """Ensure the first task is not a ConditionalTask.""" + if self.tasks and isinstance(self.tasks[0], ConditionalTask): + raise PydanticCustomError( + "invalid_first_task", + "The first task cannot be a ConditionalTask.", + {}, + ) + return self + + @model_validator(mode="after") + def validate_async_tasks_not_async(self) -> "Squad": + """Ensure that ConditionalTask is not async.""" + for task in self.tasks: + if task.async_execution and isinstance(task, ConditionalTask): + raise PydanticCustomError( + "invalid_async_conditional_task", + f"Conditional Task: {task.description} , cannot be executed asynchronously.", # type: ignore # Argument of type "str" cannot be assigned to parameter "message_template" of type "LiteralString" + {}, + ) + return self + + @model_validator(mode="after") + def validate_async_task_cannot_include_sequential_async_tasks_in_context(self): + """ + Validates that if a task is set to be executed asynchronously, + it cannot include other asynchronous tasks in its context unless + separated by a synchronous task. + """ + for i, task in enumerate(self.tasks): + if task.async_execution and task.context: + for context_task in task.context: + if context_task.async_execution: + for j in range(i - 1, -1, -1): + if self.tasks[j] == context_task: + raise ValueError( + f"Task '{task.description}' is asynchronous and cannot include other sequential asynchronous tasks in its context." + ) + if not self.tasks[j].async_execution: + break + return self + + @model_validator(mode="after") + def validate_context_no_future_tasks(self): + """Validates that a task's context does not include future tasks.""" + task_indices = {id(task): i for i, task in enumerate(self.tasks)} + + for task in self.tasks: + if task.context: + for context_task in task.context: + if id(context_task) not in task_indices: + continue # Skip context tasks not in the main tasks list + if task_indices[id(context_task)] > task_indices[id(task)]: + raise ValueError( + f"Task '{task.description}' has a context dependency on a future task '{context_task.description}', which is not allowed." + ) + return self + + @property + def key(self) -> str: + source = [agent.key for agent in self.agents] + [ + task.key for task in self.tasks + ] + return md5("|".join(source).encode(), usedforsecurity=False).hexdigest() + + def _setup_from_config(self): + assert self.config is not None, "Config should not be None." + + """Initializes agents and tasks from the provided config.""" + if not self.config.get("agents") or not self.config.get("tasks"): + raise PydanticCustomError( + "missing_keys_in_config", "Config should have 'agents' and 'tasks'.", {} + ) + + self.process = self.config.get("process", self.process) + self.agents = [Agent(**agent) for agent in self.config["agents"]] + self.tasks = [self._create_task(task) for task in self.config["tasks"]] + + def _create_task(self, task_config: Dict[str, Any]) -> Task: + """Creates a task instance from its configuration. + + Args: + task_config: The configuration of the task. + + Returns: + A task instance. + """ + task_agent = next( + agt for agt in self.agents if agt.role == task_config["agent"] + ) + del task_config["agent"] + return Task(**task_config, agent=task_agent) + + def _setup_for_training(self, filename: str) -> None: + """Sets up the squad for training.""" + self._train = True + + for task in self.tasks: + task.human_input = True + + for agent in self.agents: + agent.allow_delegation = False + + SquadTrainingHandler(TRAINING_DATA_FILE).initialize_file() + SquadTrainingHandler(filename).initialize_file() + + def train( + self, n_iterations: int, filename: str, inputs: Optional[Dict[str, Any]] = {} + ) -> None: + """Trains the squad for a given number of iterations.""" + self._setup_for_training(filename) + + for n_iteration in range(n_iterations): + self._train_iteration = n_iteration + self.kickoff(inputs=inputs) + + training_data = SquadTrainingHandler(TRAINING_DATA_FILE).load() + + for agent in self.agents: + result = TaskEvaluator(agent).evaluate_training_data( + training_data=training_data, agent_id=str(agent.id) + ) + + SquadTrainingHandler(filename).save_trained_data( + agent_id=str(agent.role), trained_data=result.model_dump() + ) + + def kickoff( + self, + inputs: Optional[Dict[str, Any]] = None, + ) -> SquadOutput: + """Starts the squad to work on its assigned tasks.""" + self._task_output_handler.reset() + self._logging_color = "bold_purple" + + if inputs is not None: + self._inputs = inputs + self._interpolate_inputs(inputs) + self._set_tasks_callbacks() + + i18n = I18N(prompt_file=self.prompt_file) + + for agent in self.agents: + agent.i18n = i18n + # type: ignore[attr-defined] # Argument 1 to "_interpolate_inputs" of "Squad" has incompatible type "dict[str, Any] | None"; expected "dict[str, Any]" + agent.squad = self # type: ignore[attr-defined] + # TODO: Create an AgentFunctionCalling protocol for future refactoring + if not agent.function_calling_llm: # type: ignore # "BaseAgent" has no attribute "function_calling_llm" + agent.function_calling_llm = self.function_calling_llm # type: ignore # "BaseAgent" has no attribute "function_calling_llm" + + if agent.allow_code_execution: # type: ignore # BaseAgent" has no attribute "allow_code_execution" + agent.tools += agent.get_code_execution_tools() # type: ignore # "BaseAgent" has no attribute "get_code_execution_tools"; maybe "get_delegation_tools"? + + if not agent.step_callback: # type: ignore # "BaseAgent" has no attribute "step_callback" + agent.step_callback = self.step_callback # type: ignore # "BaseAgent" has no attribute "step_callback" + + agent.create_agent_executor() + + if self.planning: + self._handle_squad_planning() + + metrics: List[UsageMetrics] = [] + + if self.process == Process.sequential: + result = self._run_sequential_process() + elif self.process == Process.hierarchical: + result = self._run_hierarchical_process() + else: + raise NotImplementedError( + f"The process '{self.process}' is not implemented yet." + ) + + metrics += [agent._token_process.get_summary() for agent in self.agents] + + self.usage_metrics = UsageMetrics() + for metric in metrics: + self.usage_metrics.add_usage_metrics(metric) + + return result + + def kickoff_for_each(self, inputs: List[Dict[str, Any]]) -> List[SquadOutput]: + """Executes the Squad's workflow for each input in the list and aggregates results.""" + results: List[SquadOutput] = [] + + # Initialize the parent squad's usage metrics + total_usage_metrics = UsageMetrics() + + for input_data in inputs: + squad = self.copy() + + output = squad.kickoff(inputs=input_data) + + if squad.usage_metrics: + total_usage_metrics.add_usage_metrics(squad.usage_metrics) + + results.append(output) + + self.usage_metrics = total_usage_metrics + self._task_output_handler.reset() + return results + + async def kickoff_async(self, inputs: Optional[Dict[str, Any]] = {}) -> SquadOutput: + """Asynchronous kickoff method to start the squad execution.""" + return await asyncio.to_thread(self.kickoff, inputs) + + async def kickoff_for_each_async(self, inputs: List[Dict]) -> List[SquadOutput]: + squad_copies = [self.copy() for _ in inputs] + + async def run_squad(squad, input_data): + return await squad.kickoff_async(inputs=input_data) + + tasks = [ + asyncio.create_task(run_squad(squad_copies[i], inputs[i])) + for i in range(len(inputs)) + ] + tasks = [ + asyncio.create_task(run_squad(squad_copies[i], inputs[i])) + for i in range(len(inputs)) + ] + + results = await asyncio.gather(*tasks) + + total_usage_metrics = UsageMetrics() + for squad in squad_copies: + if squad.usage_metrics: + total_usage_metrics.add_usage_metrics(squad.usage_metrics) + + self.usage_metrics = total_usage_metrics + self._task_output_handler.reset() + return results + + def _handle_squad_planning(self): + """Handles the Squad planning.""" + self._logger.log("info", "Planning the squad execution") + result = SquadPlanner( + tasks=self.tasks, planning_agent_llm=self.planning_llm + )._handle_squad_planning() + + for task, step_plan in zip(self.tasks, result.list_of_plans_per_task): + task.description += step_plan.plan + + def _store_execution_log( + self, + task: Task, + output: TaskOutput, + task_index: int, + was_replayed: bool = False, + ): + if self._inputs: + inputs = self._inputs + else: + inputs = {} + + log = { + "task": task, + "output": { + "description": output.description, + "summary": output.summary, + "raw": output.raw, + "pydantic": output.pydantic, + "json_dict": output.json_dict, + "output_format": output.output_format, + "agent": output.agent, + }, + "task_index": task_index, + "inputs": inputs, + "was_replayed": was_replayed, + } + self._task_output_handler.update(task_index, log) + + def _run_sequential_process(self) -> SquadOutput: + """Executes tasks sequentially and returns the final output.""" + return self._execute_tasks(self.tasks) + + def _run_hierarchical_process(self) -> SquadOutput: + """Creates and assigns a manager agent to make sure the squad completes the tasks.""" + self._create_manager_agent() + return self._execute_tasks(self.tasks) + + def _create_manager_agent(self): + i18n = I18N(prompt_file=self.prompt_file) + if self.manager_agent is not None: + self.manager_agent.allow_delegation = True + manager = self.manager_agent + if manager.tools is not None and len(manager.tools) > 0: + raise Exception("Manager agent should not have tools") + manager.tools = self.manager_agent.get_delegation_tools(self.agents) + else: + manager = Agent( + role=i18n.retrieve("hierarchical_manager_agent", "role"), + goal=i18n.retrieve("hierarchical_manager_agent", "goal"), + backstory=i18n.retrieve("hierarchical_manager_agent", "backstory"), + tools=AgentTools(agents=self.agents).tools(), + llm=self.manager_llm, + verbose=self.verbose, + ) + self.manager_agent = manager + + def _execute_tasks( + self, + tasks: List[Task], + start_index: Optional[int] = 0, + was_replayed: bool = False, + ) -> SquadOutput: + """Executes tasks sequentially and returns the final output. + + Args: + tasks (List[Task]): List of tasks to execute + manager (Optional[BaseAgent], optional): Manager agent to use for delegation. Defaults to None. + + Returns: + SquadOutput: Final output of the squad + """ + + task_outputs: List[TaskOutput] = [] + futures: List[Tuple[Task, Future[TaskOutput], int]] = [] + last_sync_output: Optional[TaskOutput] = None + + for task_index, task in enumerate(tasks): + if start_index is not None and task_index < start_index: + if task.output: + if task.async_execution: + task_outputs.append(task.output) + else: + task_outputs = [task.output] + last_sync_output = task.output + continue + + agent_to_use = self._get_agent_to_use(task) + if agent_to_use is None: + raise ValueError( + f"No agent available for task: {task.description}. Ensure that either the task has an assigned agent or a manager agent is provided." + ) + + self._prepare_agent_tools(task) + self._log_task_start(task, agent_to_use.role) + + if isinstance(task, ConditionalTask): + skipped_task_output = self._handle_conditional_task( + task, task_outputs, futures, task_index, was_replayed + ) + if skipped_task_output: + continue + + if task.async_execution: + context = self._get_context( + task, [last_sync_output] if last_sync_output else [] + ) + future = task.execute_async( + agent=agent_to_use, + context=context, + tools=agent_to_use.tools, + ) + futures.append((task, future, task_index)) + else: + if futures: + task_outputs = self._process_async_tasks(futures, was_replayed) + futures.clear() + + context = self._get_context(task, task_outputs) + task_output = task.execute_sync( + agent=agent_to_use, + context=context, + tools=agent_to_use.tools, + ) + task_outputs = [task_output] + self._process_task_result(task, task_output) + self._store_execution_log(task, task_output, task_index, was_replayed) + + if futures: + task_outputs = self._process_async_tasks(futures, was_replayed) + + return self._create_squad_output(task_outputs) + + def _handle_conditional_task( + self, + task: ConditionalTask, + task_outputs: List[TaskOutput], + futures: List[Tuple[Task, Future[TaskOutput], int]], + task_index: int, + was_replayed: bool, + ) -> Optional[TaskOutput]: + if futures: + task_outputs = self._process_async_tasks(futures, was_replayed) + futures.clear() + + previous_output = task_outputs[task_index - 1] if task_outputs else None + if previous_output is not None and not task.should_execute(previous_output): + self._logger.log( + "debug", + f"Skipping conditional task: {task.description}", + color="yellow", + ) + skipped_task_output = task.get_skipped_task_output() + + if not was_replayed: + self._store_execution_log(task, skipped_task_output, task_index) + return skipped_task_output + return None + + def _prepare_agent_tools(self, task: Task): + if self.process == Process.hierarchical: + if self.manager_agent: + self._update_manager_tools(task) + else: + raise ValueError("Manager agent is required for hierarchical process.") + elif task.agent and task.agent.allow_delegation: + self._add_delegation_tools(task) + + def _get_agent_to_use(self, task: Task) -> Optional[BaseAgent]: + if self.process == Process.hierarchical: + return self.manager_agent + return task.agent + + def _add_delegation_tools(self, task: Task): + agents_for_delegation = [agent for agent in self.agents if agent != task.agent] + if len(self.agents) > 1 and len(agents_for_delegation) > 0 and task.agent: + delegation_tools = task.agent.get_delegation_tools(agents_for_delegation) + + # Add tools if they are not already in task.tools + for new_tool in delegation_tools: + # Find the index of the tool with the same name + existing_tool_index = next( + ( + index + for index, tool in enumerate(task.tools or []) + if tool.name == new_tool.name + ), + None, + ) + if not task.tools: + task.tools = [] + + if existing_tool_index is not None: + # Replace the existing tool + task.tools[existing_tool_index] = new_tool + else: + # Add the new tool + task.tools.append(new_tool) + + def _log_task_start(self, task: Task, role: str = "None"): + color = self._logging_color + self._logger.log("debug", f"== Working Agent: {role}", color=color) + self._logger.log("info", f"== Starting Task: {task.description}", color=color) + if self.output_log_file: + self._file_handler.log(agent=role, task=task.description, status="started") + + def _update_manager_tools(self, task: Task): + if self.manager_agent: + if task.agent: + self.manager_agent.tools = task.agent.get_delegation_tools([task.agent]) + else: + self.manager_agent.tools = self.manager_agent.get_delegation_tools( + self.agents + ) + + def _get_context(self, task: Task, task_outputs: List[TaskOutput]): + context = ( + aggregate_raw_outputs_from_tasks(task.context) + if task.context + else aggregate_raw_outputs_from_task_outputs(task_outputs) + ) + return context + + def _process_task_result(self, task: Task, output: TaskOutput) -> None: + role = task.agent.role if task.agent is not None else "None" + self._logger.log("debug", f"== [{role}] Task output: {output}\n\n") + if self.output_log_file: + self._file_handler.log(agent=role, task=output, status="completed") + + def _create_squad_output(self, task_outputs: List[TaskOutput]) -> SquadOutput: + if len(task_outputs) != 1: + raise ValueError( + "Something went wrong. Kickoff should return only one task output." + ) + final_task_output = task_outputs[0] + final_string_output = final_task_output.raw + self._finish_execution(final_string_output) + token_usage = self.calculate_usage_metrics() + + return SquadOutput( + raw=final_task_output.raw, + pydantic=final_task_output.pydantic, + json_dict=final_task_output.json_dict, + tasks_output=[task.output for task in self.tasks if task.output], + token_usage=token_usage, + ) + + def _process_async_tasks( + self, + futures: List[Tuple[Task, Future[TaskOutput], int]], + was_replayed: bool = False, + ) -> List[TaskOutput]: + task_outputs: List[TaskOutput] = [] + for future_task, future, task_index in futures: + task_output = future.result() + task_outputs.append(task_output) + self._process_task_result(future_task, task_output) + self._store_execution_log( + future_task, task_output, task_index, was_replayed + ) + return task_outputs + + def _find_task_index( + self, task_id: str, stored_outputs: List[Any] + ) -> Optional[int]: + return next( + ( + index + for (index, d) in enumerate(stored_outputs) + if d["task_id"] == str(task_id) + ), + None, + ) + + def replay( + self, task_id: str, inputs: Optional[Dict[str, Any]] = None + ) -> SquadOutput: + stored_outputs = self._task_output_handler.load() + if not stored_outputs: + raise ValueError(f"Task with id {task_id} not found in the squad's tasks.") + + start_index = self._find_task_index(task_id, stored_outputs) + + if start_index is None: + raise ValueError(f"Task with id {task_id} not found in the squad's tasks.") + + replay_inputs = ( + inputs if inputs is not None else stored_outputs[start_index]["inputs"] + ) + self._inputs = replay_inputs + + if replay_inputs: + self._interpolate_inputs(replay_inputs) + + if self.process == Process.hierarchical: + self._create_manager_agent() + + for i in range(start_index): + stored_output = stored_outputs[i][ + "output" + ] # for adding context to the task + task_output = TaskOutput( + description=stored_output["description"], + agent=stored_output["agent"], + raw=stored_output["raw"], + pydantic=stored_output["pydantic"], + json_dict=stored_output["json_dict"], + output_format=stored_output["output_format"], + ) + self.tasks[i].output = task_output + + self._logging_color = "bold_blue" + result = self._execute_tasks(self.tasks, start_index, True) + return result + + def copy(self): + """Create a deep copy of the Squad.""" + + exclude = { + "id", + "_rpm_controller", + "_logger", + "_execution_span", + "_file_handler", + "_cache_handler", + "_short_term_memory", + "_long_term_memory", + "_entity_memory", + "agents", + "tasks", + } + + cloned_agents = [agent.copy() for agent in self.agents] + cloned_tasks = [task.copy(cloned_agents) for task in self.tasks] + + copied_data = self.model_dump(exclude=exclude) + copied_data = {k: v for k, v in copied_data.items() if v is not None} + + copied_data.pop("agents", None) + copied_data.pop("tasks", None) + + copied_squad = Squad(**copied_data, agents=cloned_agents, tasks=cloned_tasks) + + return copied_squad + + def _set_tasks_callbacks(self) -> None: + """Sets callback for every task suing task_callback""" + for task in self.tasks: + if not task.callback: + task.callback = self.task_callback + + def _interpolate_inputs(self, inputs: Dict[str, Any]) -> None: + """Interpolates the inputs in the tasks and agents.""" + [ + task.interpolate_inputs( + # type: ignore # "interpolate_inputs" of "Task" does not return a value (it only ever returns None) + inputs + ) + for task in self.tasks + ] + # type: ignore # "interpolate_inputs" of "Agent" does not return a value (it only ever returns None) + for agent in self.agents: + agent.interpolate_inputs(inputs) + + def _finish_execution(self, final_string_output: str) -> None: + if self.max_rpm: + self._rpm_controller.stop_rpm_counter() + + def calculate_usage_metrics(self) -> UsageMetrics: + """Calculates and returns the usage metrics.""" + total_usage_metrics = UsageMetrics() + + for agent in self.agents: + if hasattr(agent, "_token_process"): + token_sum = agent._token_process.get_summary() + total_usage_metrics.add_usage_metrics(token_sum) + + if self.manager_agent and hasattr(self.manager_agent, "_token_process"): + token_sum = self.manager_agent._token_process.get_summary() + total_usage_metrics.add_usage_metrics(token_sum) + + return total_usage_metrics + + def test( + self, + n_iterations: int, + groq_model_name: str, + inputs: Optional[Dict[str, Any]] = None, + ) -> None: + """Test and evaluate the Squad with the given inputs for n iterations.""" + evaluator = SquadEvaluator(self, groq_model_name) + + for i in range(1, n_iterations + 1): + evaluator.set_iteration(i) + self.kickoff(inputs=inputs) + + evaluator.print_squad_evaluation_result() + + def __rshift__(self, other: "Squad") -> "Pipeline": + """ + Implements the >> operator to add another Squad to an existing Pipeline. + """ + from squadai.pipeline.pipeline import Pipeline + + if not isinstance(other, Squad): + raise TypeError( + f"Unsupported operand type for >>: '{type(self).__name__}' and '{type(other).__name__}'" + ) + return Pipeline(stages=[self, other]) + + def __repr__(self): + return f"Squad(id={self.id}, process={self.process}, number_of_agents={len(self.agents)}, number_of_tasks={len(self.tasks)})" diff --git a/squadai/squadai_tools/__init__.py b/squadai/squadai_tools/__init__.py new file mode 100644 index 0000000..b5dcc81 --- /dev/null +++ b/squadai/squadai_tools/__init__.py @@ -0,0 +1,44 @@ +from .tools import ( + BrowserbaseLoadTool, + CodeDocsSearchTool, + CodeInterpreterTool, + ComposioTool, + CSVSearchTool, + DallETool, + DirectoryReadTool, + DirectorySearchTool, + DOCXSearchTool, + EXASearchTool, + FileReadTool, + FileWriterTool, + FirecrawlCrawlWebsiteTool, + FirecrawlScrapeWebsiteTool, + FirecrawlSearchTool, + GithubSearchTool, + JSONSearchTool, + LlamaIndexTool, + MDXSearchTool, + MultiOnTool, + NL2SQLTool, + PDFSearchTool, + PGSearchTool, + RagTool, + ScrapeElementFromWebsiteTool, + ScrapeWebsiteTool, + ScrapflyScrapeWebsiteTool, + SeleniumScrapingTool, + SerperDevTool, + SerplyJobSearchTool, + SerplyNewsSearchTool, + SerplyScholarSearchTool, + SerplyWebpageToMarkdownTool, + SerplyWebSearchTool, + TXTSearchTool, + VisionTool, + WebsiteSearchTool, + XMLSearchTool, + YoutubeChannelSearchTool, + YoutubeVideoSearchTool, + MySQLSearchTool +) +from .tools.base_tool import BaseTool, Tool, tool diff --git a/squadai/squadai_tools/adapters/embedchain_adapter.py b/squadai/squadai_tools/adapters/embedchain_adapter.py new file mode 100644 index 0000000..b746e2f --- /dev/null +++ b/squadai/squadai_tools/adapters/embedchain_adapter.py @@ -0,0 +1,25 @@ +from typing import Any + +from embedchain import App + +from squadai_tools.tools.rag.rag_tool import Adapter + + +class EmbedchainAdapter(Adapter): + embedchain_app: App + summarize: bool = False + + def query(self, question: str) -> str: + result, sources = self.embedchain_app.query( + question, citations=True, dry_run=(not self.summarize) + ) + if self.summarize: + return result + return "\n\n".join([source[0] for source in sources]) + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + self.embedchain_app.add(*args, **kwargs) diff --git a/squadai/squadai_tools/adapters/lancedb_adapter.py b/squadai/squadai_tools/adapters/lancedb_adapter.py new file mode 100644 index 0000000..84f0e1e --- /dev/null +++ b/squadai/squadai_tools/adapters/lancedb_adapter.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Any, Callable + +from lancedb import DBConnection as LanceDBConnection +from lancedb import connect as lancedb_connect +from lancedb.table import Table as LanceDBTable +from openai import Client as OpenAIClient +from pydantic import Field, PrivateAttr + +from squadai_tools.tools.rag.rag_tool import Adapter + + +def _default_embedding_function(): + client = OpenAIClient() + + def _embedding_function(input): + rs = client.embeddings.create(input=input, model="text-embedding-ada-002") + return [record.embedding for record in rs.data] + + return _embedding_function + + +class LanceDBAdapter(Adapter): + uri: str | Path + table_name: str + embedding_function: Callable = Field(default_factory=_default_embedding_function) + top_k: int = 3 + vector_column_name: str = "vector" + text_column_name: str = "text" + + _db: LanceDBConnection = PrivateAttr() + _table: LanceDBTable = PrivateAttr() + + def model_post_init(self, __context: Any) -> None: + self._db = lancedb_connect(self.uri) + self._table = self._db.open_table(self.table_name) + + super().model_post_init(__context) + + def query(self, question: str) -> str: + query = self.embedding_function([question])[0] + results = ( + self._table.search(query, vector_column_name=self.vector_column_name) + .limit(self.top_k) + .select([self.text_column_name]) + .to_list() + ) + values = [result[self.text_column_name] for result in results] + return "\n".join(values) + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + self._table.add(*args, **kwargs) diff --git a/squadai/squadai_tools/adapters/pdf_embedchain_adapter.py b/squadai/squadai_tools/adapters/pdf_embedchain_adapter.py new file mode 100644 index 0000000..d77a868 --- /dev/null +++ b/squadai/squadai_tools/adapters/pdf_embedchain_adapter.py @@ -0,0 +1,32 @@ +from typing import Any, Optional + +from embedchain import App + +from squadai_tools.tools.rag.rag_tool import Adapter + + +class PDFEmbedchainAdapter(Adapter): + embedchain_app: App + summarize: bool = False + src: Optional[str] = None + + def query(self, question: str) -> str: + where = ( + {"app_id": self.embedchain_app.config.id, "source": self.src} + if self.src + else None + ) + result, sources = self.embedchain_app.query( + question, citations=True, dry_run=(not self.summarize), where=where + ) + if self.summarize: + return result + return "\n\n".join([source[0] for source in sources]) + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + self.src = args[0] if args else None + self.embedchain_app.add(*args, **kwargs) diff --git a/squadai/squadai_tools/tools/__init__.py b/squadai/squadai_tools/tools/__init__.py new file mode 100644 index 0000000..9016c57 --- /dev/null +++ b/squadai/squadai_tools/tools/__init__.py @@ -0,0 +1,52 @@ +from .browserbase_load_tool.browserbase_load_tool import BrowserbaseLoadTool +from .code_docs_search_tool.code_docs_search_tool import CodeDocsSearchTool +from .code_interpreter_tool.code_interpreter_tool import CodeInterpreterTool +from .composio_tool.composio_tool import ComposioTool +from .csv_search_tool.csv_search_tool import CSVSearchTool +from .dalle_tool.dalle_tool import DallETool +from .directory_read_tool.directory_read_tool import DirectoryReadTool +from .directory_search_tool.directory_search_tool import DirectorySearchTool +from .docx_search_tool.docx_search_tool import DOCXSearchTool +from .exa_tools.exa_search_tool import EXASearchTool +from .file_read_tool.file_read_tool import FileReadTool +from .file_writer_tool.file_writer_tool import FileWriterTool +from .firecrawl_crawl_website_tool.firecrawl_crawl_website_tool import ( + FirecrawlCrawlWebsiteTool +) +from .firecrawl_scrape_website_tool.firecrawl_scrape_website_tool import ( + FirecrawlScrapeWebsiteTool +) +from .firecrawl_search_tool.firecrawl_search_tool import FirecrawlSearchTool +from .github_search_tool.github_search_tool import GithubSearchTool +from .json_search_tool.json_search_tool import JSONSearchTool +from .llamaindex_tool.llamaindex_tool import LlamaIndexTool +from .mdx_seach_tool.mdx_search_tool import MDXSearchTool +from .multion_tool.multion_tool import MultiOnTool +from .nl2sql.nl2sql_tool import NL2SQLTool +from .pdf_search_tool.pdf_search_tool import PDFSearchTool +from .pg_seach_tool.pg_search_tool import PGSearchTool +from .rag.rag_tool import RagTool +from .scrape_element_from_website.scrape_element_from_website import ( + ScrapeElementFromWebsiteTool +) +from .scrape_website_tool.scrape_website_tool import ScrapeWebsiteTool +from .scrapfly_scrape_website_tool.scrapfly_scrape_website_tool import ( + ScrapflyScrapeWebsiteTool +) +from .selenium_scraping_tool.selenium_scraping_tool import SeleniumScrapingTool +from .serper_dev_tool.serper_dev_tool import SerperDevTool +from .serply_api_tool.serply_job_search_tool import SerplyJobSearchTool +from .serply_api_tool.serply_news_search_tool import SerplyNewsSearchTool +from .serply_api_tool.serply_scholar_search_tool import SerplyScholarSearchTool +from .serply_api_tool.serply_web_search_tool import SerplyWebSearchTool +from .serply_api_tool.serply_webpage_to_markdown_tool import SerplyWebpageToMarkdownTool +from .spider_tool.spider_tool import SpiderTool +from .txt_search_tool.txt_search_tool import TXTSearchTool +from .vision_tool.vision_tool import VisionTool +from .website_search.website_search_tool import WebsiteSearchTool +from .xml_search_tool.xml_search_tool import XMLSearchTool +from .youtube_channel_search_tool.youtube_channel_search_tool import ( + YoutubeChannelSearchTool +) +from .youtube_video_search_tool.youtube_video_search_tool import YoutubeVideoSearchTool +from .mysql_search_tool.mysql_search_tool import MySQLSearchTool diff --git a/squadai/squadai_tools/tools/base_tool.py b/squadai/squadai_tools/tools/base_tool.py new file mode 100644 index 0000000..4b60d93 --- /dev/null +++ b/squadai/squadai_tools/tools/base_tool.py @@ -0,0 +1,151 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable, Optional, Type + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, ConfigDict, Field, validator +from pydantic.v1 import BaseModel as V1BaseModel + + +class BaseTool(BaseModel, ABC): + class _ArgsSchemaPlaceholder(V1BaseModel): + pass + + model_config = ConfigDict() + + name: str + """The unique name of the tool that clearly communicates its purpose.""" + description: str + """Used to tell the model how/when/why to use the tool.""" + args_schema: Type[V1BaseModel] = Field(default_factory=_ArgsSchemaPlaceholder) + """The schema for the arguments that the tool accepts.""" + description_updated: bool = False + """Flag to check if the description has been updated.""" + cache_function: Optional[Callable] = lambda _args, _result: True + """Function that will be used to determine if the tool should be cached, should return a boolean. If None, the tool will be cached.""" + result_as_answer: bool = False + """Flag to check if the tool should be the final agent answer.""" + + @validator("args_schema", always=True, pre=True) + def _default_args_schema(cls, v: Type[V1BaseModel]) -> Type[V1BaseModel]: + if not isinstance(v, cls._ArgsSchemaPlaceholder): + return v + + return type( + f"{cls.__name__}Schema", + (V1BaseModel,), + { + "__annotations__": { + k: v for k, v in cls._run.__annotations__.items() if k != "return" + }, + }, + ) + + def model_post_init(self, __context: Any) -> None: + self._generate_description() + + super().model_post_init(__context) + + def run( + self, + *args: Any, + **kwargs: Any, + ) -> Any: + print(f"Using Tool: {self.name}") + return self._run(*args, **kwargs) + + @abstractmethod + def _run( + self, + *args: Any, + **kwargs: Any, + ) -> Any: + """Here goes the actual implementation of the tool.""" + + def to_langchain(self) -> StructuredTool: + self._set_args_schema() + return StructuredTool( + name=self.name, + description=self.description, + args_schema=self.args_schema, + func=self._run, + ) + + def _set_args_schema(self): + if self.args_schema is None: + class_name = f"{self.__class__.__name__}Schema" + self.args_schema = type( + class_name, + (V1BaseModel,), + { + "__annotations__": { + k: v + for k, v in self._run.__annotations__.items() + if k != "return" + }, + }, + ) + + def _generate_description(self): + args = [] + args_description = [] + for arg, attribute in self.args_schema.schema()["properties"].items(): + if "type" in attribute: + args.append(f"{arg}: '{attribute['type']}'") + if "description" in attribute: + args_description.append(f"{arg}: '{attribute['description']}'") + + description = self.description.replace("\n", " ") + self.description = f"{self.name}({', '.join(args)}) - {description} {', '.join(args_description)}" + + +class Tool(BaseTool): + func: Callable + """The function that will be executed when the tool is called.""" + + def _run(self, *args: Any, **kwargs: Any) -> Any: + return self.func(*args, **kwargs) + + +def to_langchain( + tools: list[BaseTool | StructuredTool], +) -> list[StructuredTool]: + return [t.to_langchain() if isinstance(t, BaseTool) else t for t in tools] + + +def tool(*args): + """ + Decorator to create a tool from a function. + """ + + def _make_with_name(tool_name: str) -> Callable: + def _make_tool(f: Callable) -> BaseTool: + if f.__doc__ is None: + raise ValueError("Function must have a docstring") + if f.__annotations__ is None: + raise ValueError("Function must have type annotations") + + class_name = "".join(tool_name.split()).title() + args_schema = type( + class_name, + (V1BaseModel,), + { + "__annotations__": { + k: v for k, v in f.__annotations__.items() if k != "return" + }, + }, + ) + + return Tool( + name=tool_name, + description=f.__doc__, + func=f, + args_schema=args_schema, + ) + + return _make_tool + + if len(args) == 1 and callable(args[0]): + return _make_with_name(args[0].__name__)(args[0]) + if len(args) == 1 and isinstance(args[0], str): + return _make_with_name(args[0]) + raise ValueError("Invalid arguments") diff --git a/squadai/squadai_tools/tools/browserbase_load_tool/README.md b/squadai/squadai_tools/tools/browserbase_load_tool/README.md new file mode 100644 index 0000000..f24f9aa --- /dev/null +++ b/squadai/squadai_tools/tools/browserbase_load_tool/README.md @@ -0,0 +1,38 @@ +# BrowserbaseLoadTool + +## Description + +[Browserbase](https://browserbase.com) is a developer platform to reliably run, manage, and monitor headless browsers. + + Power your AI data retrievals with: + - [Serverless Infrastructure](https://docs.browserbase.com/under-the-hood) providing reliable browsers to extract data from complex UIs + - [Stealth Mode](https://docs.browserbase.com/features/stealth-mode) with included fingerprinting tactics and automatic captcha solving + - [Session Debugger](https://docs.browserbase.com/features/sessions) to inspect your Browser Session with networks timeline and logs + - [Live Debug](https://docs.browserbase.com/guides/session-debug-connection/browser-remote-control) to quickly debug your automation + +## Installation + +- Get an API key and Project ID from [browserbase.com](https://browserbase.com) and set it in environment variables (`BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID`). +- Install the [Browserbase SDK](http://github.com/browserbase/python-sdk) along with `squadai[tools]` package: + +``` +pip install browserbase 'squadai[tools]' +``` + +## Example + +Utilize the BrowserbaseLoadTool as follows to allow your agent to load websites: + +```python +from squadai_tools import BrowserbaseLoadTool + +tool = BrowserbaseLoadTool() +``` + +## Arguments + +- `api_key` Optional. Browserbase API key. Default is `BROWSERBASE_API_KEY` env variable. +- `project_id` Optional. Browserbase Project ID. Default is `BROWSERBASE_PROJECT_ID` env variable. +- `text_content` Retrieve only text content. Default is `False`. +- `session_id` Optional. Provide an existing Session ID. +- `proxy` Optional. Enable/Disable Proxies." diff --git a/squadai/squadai_tools/tools/browserbase_load_tool/browserbase_load_tool.py b/squadai/squadai_tools/tools/browserbase_load_tool/browserbase_load_tool.py new file mode 100644 index 0000000..d911e75 --- /dev/null +++ b/squadai/squadai_tools/tools/browserbase_load_tool/browserbase_load_tool.py @@ -0,0 +1,44 @@ +from typing import Optional, Any, Type +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class BrowserbaseLoadToolSchema(BaseModel): + url: str = Field(description="Website URL") + +class BrowserbaseLoadTool(BaseTool): + name: str = "Browserbase web load tool" + description: str = "Load webpages url in a headless browser using Browserbase and return the contents" + args_schema: Type[BaseModel] = BrowserbaseLoadToolSchema + api_key: Optional[str] = None + project_id: Optional[str] = None + text_content: Optional[bool] = False + session_id: Optional[str] = None + proxy: Optional[bool] = None + browserbase: Optional[Any] = None + + def __init__( + self, + api_key: Optional[str] = None, + project_id: Optional[str] = None, + text_content: Optional[bool] = False, + session_id: Optional[str] = None, + proxy: Optional[bool] = None, + **kwargs, + ): + super().__init__(**kwargs) + try: + from browserbase import Browserbase # type: ignore + except ImportError: + raise ImportError( + "`browserbase` package not found, please run `pip install browserbase`" + ) + + self.browserbase = Browserbase(api_key, project_id) + self.text_content = text_content + self.session_id = session_id + self.proxy = proxy + + def _run(self, url: str): + return self.browserbase.load_url( + url, self.text_content, self.session_id, self.proxy + ) diff --git a/squadai/squadai_tools/tools/code_docs_search_tool/README.md b/squadai/squadai_tools/tools/code_docs_search_tool/README.md new file mode 100644 index 0000000..a948eca --- /dev/null +++ b/squadai/squadai_tools/tools/code_docs_search_tool/README.md @@ -0,0 +1,56 @@ +# CodeDocsSearchTool + +## Description +The CodeDocsSearchTool is a powerful RAG (Retrieval-Augmented Generation) tool designed for semantic searches within code documentation. It enables users to efficiently find specific information or topics within code documentation. By providing a `docs_url` during initialization, the tool narrows down the search to that particular documentation site. Alternatively, without a specific `docs_url`, it searches across a wide array of code documentation known or discovered throughout its execution, making it versatile for various documentation search needs. + +## Installation +To start using the CodeDocsSearchTool, first, install the squadai_tools package via pip: +```shell +pip install 'squadai[tools]' +``` + +## Example +Utilize the CodeDocsSearchTool as follows to conduct searches within code documentation: +```python +from squadai_tools import CodeDocsSearchTool + +# To search any code documentation content if the URL is known or discovered during its execution: +tool = CodeDocsSearchTool() + +# OR + +# To specifically focus your search on a given documentation site by providing its URL: +tool = CodeDocsSearchTool(docs_url='https://docs.example.com/reference') +``` +Note: Substitute 'https://docs.example.com/reference' with your target documentation URL and 'How to use search tool' with the search query relevant to your needs. + +## Arguments +- `docs_url`: Optional. Specifies the URL of the code documentation to be searched. Providing this during the tool's initialization focuses the search on the specified documentation content. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = YoutubeVideoSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/code_docs_search_tool/code_docs_search_tool.py b/squadai/squadai_tools/tools/code_docs_search_tool/code_docs_search_tool.py new file mode 100644 index 0000000..8999435 --- /dev/null +++ b/squadai/squadai_tools/tools/code_docs_search_tool/code_docs_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedCodeDocsSearchToolSchema(BaseModel): + """Input for CodeDocsSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the Code Docs content", + ) + + +class CodeDocsSearchToolSchema(FixedCodeDocsSearchToolSchema): + """Input for CodeDocsSearchTool.""" + + docs_url: str = Field(..., description="Mandatory docs_url path you want to search") + + +class CodeDocsSearchTool(RagTool): + name: str = "Search a Code Docs content" + description: str = ( + "A tool that can be used to semantic search a query from a Code Docs content." + ) + args_schema: Type[BaseModel] = CodeDocsSearchToolSchema + + def __init__(self, docs_url: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if docs_url is not None: + self.add(docs_url) + self.description = f"A tool that can be used to semantic search a query the {docs_url} Code Docs content." + self.args_schema = FixedCodeDocsSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.DOCS_SITE + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "docs_url" in kwargs: + self.add(kwargs["docs_url"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/code_interpreter_tool/Dockerfile b/squadai/squadai_tools/tools/code_interpreter_tool/Dockerfile new file mode 100644 index 0000000..ae9b2ff --- /dev/null +++ b/squadai/squadai_tools/tools/code_interpreter_tool/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +# Install common utilities +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + wget \ + software-properties-common + +# Clean up +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + +# Set the working directory +WORKDIR /workspace diff --git a/squadai/squadai_tools/tools/code_interpreter_tool/README.md b/squadai/squadai_tools/tools/code_interpreter_tool/README.md new file mode 100644 index 0000000..3c3b1dc --- /dev/null +++ b/squadai/squadai_tools/tools/code_interpreter_tool/README.md @@ -0,0 +1,29 @@ +# CodeInterpreterTool + +## Description +This tool is used to give the Agent the ability to run code (Python3) from the code generated by the Agent itself. The code is executed in a sandboxed environment, so it is safe to run any code. + +It is incredible useful since it allows the Agent to generate code, run it in the same environment, get the result and use it to make decisions. + +## Requirements + +- Docker + +## Installation +Install the squadai_tools package +```shell +pip install 'squadai[tools]' +``` + +## Example + +Remember that when using this tool, the code must be generated by the Agent itself. The code must be a Python3 code. And it will take some time for the first time to run because it needs to build the Docker image. + +```python +from squadai_tools import CodeInterpreterTool + +Agent( + ... + tools=[CodeInterpreterTool()], +) +``` diff --git a/squadai/squadai_tools/tools/code_interpreter_tool/code_interpreter_tool.py b/squadai/squadai_tools/tools/code_interpreter_tool/code_interpreter_tool.py new file mode 100644 index 0000000..32c61a3 --- /dev/null +++ b/squadai/squadai_tools/tools/code_interpreter_tool/code_interpreter_tool.py @@ -0,0 +1,94 @@ +import importlib.util +import os +from typing import List, Optional, Type + +import docker +from squadai.squadai_tools.tools.base_tool import BaseTool +from pydantic.v1 import BaseModel, Field + + +class CodeInterpreterSchema(BaseModel): + """Input for CodeInterpreterTool.""" + + code: str = Field( + ..., + description="Python3 code used to be interpreted in the Docker container. ALWAYS PRINT the final result and the output of the code", + ) + + libraries_used: List[str] = Field( + ..., + description="List of libraries used in the code with proper installing names separated by commas. Example: numpy,pandas,beautifulsoup4", + ) + + +class CodeInterpreterTool(BaseTool): + name: str = "Code Interpreter" + description: str = "Interprets Python3 code strings with a final print statement." + args_schema: Type[BaseModel] = CodeInterpreterSchema + code: Optional[str] = None + + @staticmethod + def _get_installed_package_path(): + spec = importlib.util.find_spec("squadai_tools") + return os.path.dirname(spec.origin) + + def _verify_docker_image(self) -> None: + """ + Verify if the Docker image is available + """ + image_tag = "code-interpreter:latest" + client = docker.from_env() + + try: + client.images.get(image_tag) + + except docker.errors.ImageNotFound: + package_path = self._get_installed_package_path() + dockerfile_path = os.path.join(package_path, "tools/code_interpreter_tool") + if not os.path.exists(dockerfile_path): + raise FileNotFoundError(f"Dockerfile not found in {dockerfile_path}") + + client.images.build( + path=dockerfile_path, + tag=image_tag, + rm=True, + ) + + def _run(self, **kwargs) -> str: + code = kwargs.get("code", self.code) + libraries_used = kwargs.get("libraries_used", []) + return self.run_code_in_docker(code, libraries_used) + + def _install_libraries( + self, container: docker.models.containers.Container, libraries: List[str] + ) -> None: + """ + Install missing libraries in the Docker container + """ + for library in libraries: + container.exec_run(f"pip install {library}") + + def _init_docker_container(self) -> docker.models.containers.Container: + client = docker.from_env() + return client.containers.run( + "code-interpreter", + detach=True, + tty=True, + working_dir="/workspace", + name="code-interpreter", + ) + + def run_code_in_docker(self, code: str, libraries_used: List[str]) -> str: + self._verify_docker_image() + container = self._init_docker_container() + self._install_libraries(container, libraries_used) + + cmd_to_run = f'python3 -c "{code}"' + exec_result = container.exec_run(cmd_to_run) + + container.stop() + container.remove() + + if exec_result.exit_code != 0: + return f"Something went wrong while running the code: \n{exec_result.output.decode('utf-8')}" + return exec_result.output.decode("utf-8") diff --git a/squadai/squadai_tools/tools/composio_tool/README.md b/squadai/squadai_tools/tools/composio_tool/README.md new file mode 100644 index 0000000..44e7fb0 --- /dev/null +++ b/squadai/squadai_tools/tools/composio_tool/README.md @@ -0,0 +1,72 @@ +# ComposioTool Documentation + +## Description + +This tools is a wrapper around the composio toolset and gives your agent access to a wide variety of tools from the composio SDK. + +## Installation + +To incorporate this tool into your project, follow the installation instructions below: + +```shell +pip install composio-core +pip install 'squadai[tools]' +``` + +after the installation is complete, either run `composio login` or export your composio API key as `COMPOSIO_API_KEY`. + +## Example + +The following example demonstrates how to initialize the tool and execute a github action: + +1. Initialize toolset + +```python +from composio import App +from squadai_tools import ComposioTool +from squadai import Agent, Task + + +tools = [ComposioTool.from_action(action=Action.GITHUB_ACTIVITY_STAR_REPO_FOR_AUTHENTICATED_USER)] +``` + +If you don't know what action you want to use, use `from_app` and `tags` filter to get relevant actions + +```python +tools = ComposioTool.from_app(App.GITHUB, tags=["important"]) +``` + +or use `use_case` to search relevant actions + +```python +tools = ComposioTool.from_app(App.GITHUB, use_case="Star a github repository") +``` + +2. Define agent + +```python +squadai_agent = Agent( + role="Github Agent", + goal="You take action on Github using Github APIs", + backstory=( + "You are AI agent that is responsible for taking actions on Github " + "on users behalf. You need to take action on Github using Github APIs" + ), + verbose=True, + tools=tools, +) +``` + +3. Execute task + +```python +task = Task( + description="Star a repo ComposioHQ/composio on GitHub", + agent=squadai_agent, + expected_output="if the star happened", +) + +task.execute() +``` + +* More detailed list of tools can be found [here](https://app.composio.dev) diff --git a/squadai/squadai_tools/tools/composio_tool/composio_tool.py b/squadai/squadai_tools/tools/composio_tool/composio_tool.py new file mode 100644 index 0000000..35016bf --- /dev/null +++ b/squadai/squadai_tools/tools/composio_tool/composio_tool.py @@ -0,0 +1,122 @@ +""" +Composio tools wrapper. +""" + +import typing as t + +import typing_extensions as te + +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class ComposioTool(BaseTool): + """Wrapper for composio tools.""" + + composio_action: t.Callable + + def _run(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Run the composio action with given arguments.""" + return self.composio_action(*args, **kwargs) + + @staticmethod + def _check_connected_account(tool: t.Any, toolset: t.Any) -> None: + """Check if connected account is required and if required it exists or not.""" + from composio import Action + from composio.client.collections import ConnectedAccountModel + + tool = t.cast(Action, tool) + if tool.no_auth: + return + + connections = t.cast( + t.List[ConnectedAccountModel], + toolset.client.connected_accounts.get(), + ) + if tool.app not in [connection.appUniqueId for connection in connections]: + raise RuntimeError( + f"No connected account found for app `{tool.app}`; " + f"Run `composio add {tool.app}` to fix this" + ) + + @classmethod + def from_action( + cls, + action: t.Any, + **kwargs: t.Any, + ) -> te.Self: + """Wrap a composio tool as squadAI tool.""" + + from composio import Action, ComposioToolSet + from composio.constants import DEFAULT_ENTITY_ID + from composio.utils.shared import json_schema_to_model + + toolset = ComposioToolSet() + if not isinstance(action, Action): + action = Action(action) + + action = t.cast(Action, action) + cls._check_connected_account( + tool=action, + toolset=toolset, + ) + + (action_schema,) = toolset.get_action_schemas(actions=[action]) + schema = action_schema.model_dump(exclude_none=True) + entity_id = kwargs.pop("entity_id", DEFAULT_ENTITY_ID) + + def function(**kwargs: t.Any) -> t.Dict: + """Wrapper function for composio action.""" + return toolset.execute_action( + action=Action(schema["name"]), + params=kwargs, + entity_id=entity_id, + ) + + function.__name__ = schema["name"] + function.__doc__ = schema["description"] + + return cls( + name=schema["name"], + description=schema["description"], + args_schema=json_schema_to_model( + action_schema.parameters.model_dump( + exclude_none=True, + ) + ), + composio_action=function, + **kwargs, + ) + + @classmethod + def from_app( + cls, + *apps: t.Any, + tags: t.Optional[t.List[str]] = None, + use_case: t.Optional[str] = None, + **kwargs: t.Any, + ) -> t.List[te.Self]: + """Create toolset from an app.""" + if len(apps) == 0: + raise ValueError("You need to provide at least one app name") + + if use_case is None and tags is None: + raise ValueError("Both `use_case` and `tags` cannot be `None`") + + if use_case is not None and tags is not None: + raise ValueError( + "Cannot use both `use_case` and `tags` to filter the actions" + ) + + from composio import ComposioToolSet + + toolset = ComposioToolSet() + if use_case is not None: + return [ + cls.from_action(action=action, **kwargs) + for action in toolset.find_actions_by_use_case(*apps, use_case=use_case) + ] + + return [ + cls.from_action(action=action, **kwargs) + for action in toolset.find_actions_by_tags(*apps, tags=tags) + ] diff --git a/squadai/squadai_tools/tools/csv_search_tool/README.md b/squadai/squadai_tools/tools/csv_search_tool/README.md new file mode 100644 index 0000000..de4f51b --- /dev/null +++ b/squadai/squadai_tools/tools/csv_search_tool/README.md @@ -0,0 +1,59 @@ +# CSVSearchTool + +## Description + +This tool is used to perform a RAG (Retrieval-Augmented Generation) search within a CSV file's content. It allows users to semantically search for queries in the content of a specified CSV file. This feature is particularly useful for extracting information from large CSV datasets where traditional search methods might be inefficient. All tools with "Search" in their name, including CSVSearchTool, are RAG tools designed for searching different sources of data. + +## Installation + +Install the squadai_tools package + +```shell +pip install 'squadai[tools]' +``` + +## Example + +```python +from squadai_tools import CSVSearchTool + +# Initialize the tool with a specific CSV file. This setup allows the agent to only search the given CSV file. +tool = CSVSearchTool(csv='path/to/your/csvfile.csv') + +# OR + +# Initialize the tool without a specific CSV file. Agent will need to provide the CSV path at runtime. +tool = CSVSearchTool() +``` + +## Arguments + +- `csv` : The path to the CSV file you want to search. This is a mandatory argument if the tool was initialized without a specific CSV file; otherwise, it is optional. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = CSVSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/csv_search_tool/csv_search_tool.py b/squadai/squadai_tools/tools/csv_search_tool/csv_search_tool.py new file mode 100644 index 0000000..9d0509f --- /dev/null +++ b/squadai/squadai_tools/tools/csv_search_tool/csv_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedCSVSearchToolSchema(BaseModel): + """Input for CSVSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the CSV's content", + ) + + +class CSVSearchToolSchema(FixedCSVSearchToolSchema): + """Input for CSVSearchTool.""" + + csv: str = Field(..., description="Mandatory csv path you want to search") + + +class CSVSearchTool(RagTool): + name: str = "Search a CSV's content" + description: str = ( + "A tool that can be used to semantic search a query from a CSV's content." + ) + args_schema: Type[BaseModel] = CSVSearchToolSchema + + def __init__(self, csv: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if csv is not None: + self.add(csv) + self.description = f"A tool that can be used to semantic search a query the {csv} CSV's content." + self.args_schema = FixedCSVSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.CSV + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "csv" in kwargs: + self.add(kwargs["csv"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/dalle_tool/README.MD b/squadai/squadai_tools/tools/dalle_tool/README.MD new file mode 100644 index 0000000..6665c66 --- /dev/null +++ b/squadai/squadai_tools/tools/dalle_tool/README.MD @@ -0,0 +1,41 @@ +# DALL-E Tool + +## Description +This tool is used to give the Agent the ability to generate images using the DALL-E model. It is a transformer-based model that generates images from textual descriptions. This tool allows the Agent to generate images based on the text input provided by the user. + +## Installation +Install the squadai_tools package +```shell +pip install 'squadai[tools]' +``` + +## Example + +Remember that when using this tool, the text must be generated by the Agent itself. The text must be a description of the image you want to generate. + +```python +from squadai_tools import DallETool + +Agent( + ... + tools=[DallETool()], +) +``` + +If needed you can also tweak the parameters of the DALL-E model by passing them as arguments to the `DallETool` class. For example: + +```python +from squadai_tools import DallETool + +dalle_tool = DallETool(model="dall-e-3", + size="1024x1024", + quality="standard", + n=1) + +Agent( + ... + tools=[dalle_tool] +) +``` + +The parameters are based on the `client.images.generate` method from the OpenAI API. For more information on the parameters, please refer to the [OpenAI API documentation](https://platform.openai.com/docs/guides/images/introduction?lang=python). diff --git a/squadai/squadai_tools/tools/dalle_tool/dalle_tool.py b/squadai/squadai_tools/tools/dalle_tool/dalle_tool.py new file mode 100644 index 0000000..7356e2e --- /dev/null +++ b/squadai/squadai_tools/tools/dalle_tool/dalle_tool.py @@ -0,0 +1,48 @@ +import json +from typing import Type + +from squadai.squadai_tools.tools.base_tool import BaseTool +from openai import OpenAI +from pydantic.v1 import BaseModel + + +class ImagePromptSchema(BaseModel): + """Input for Dall-E Tool.""" + + image_description: str = "Description of the image to be generated by Dall-E." + + +class DallETool(BaseTool): + name: str = "Dall-E Tool" + description: str = "Generates images using OpenAI's Dall-E model." + args_schema: Type[BaseModel] = ImagePromptSchema + + model: str = "dall-e-3" + size: str = "1024x1024" + quality: str = "standard" + n: int = 1 + + def _run(self, **kwargs) -> str: + client = OpenAI() + + image_description = kwargs.get("image_description") + + if not image_description: + return "Image description is required." + + response = client.images.generate( + model=self.model, + prompt=image_description, + size=self.size, + quality=self.quality, + n=self.n, + ) + + image_data = json.dumps( + { + "image_url": response.data[0].url, + "image_description": response.data[0].revised_prompt, + } + ) + + return image_data diff --git a/squadai/squadai_tools/tools/directory_read_tool/README.md b/squadai/squadai_tools/tools/directory_read_tool/README.md new file mode 100644 index 0000000..029b919 --- /dev/null +++ b/squadai/squadai_tools/tools/directory_read_tool/README.md @@ -0,0 +1,40 @@ +```markdown +# DirectoryReadTool + +## Description +The DirectoryReadTool is a highly efficient utility designed for the comprehensive listing of directory contents. It recursively navigates through the specified directory, providing users with a detailed enumeration of all files, including those nested within subdirectories. This tool is indispensable for tasks requiring a thorough inventory of directory structures or for validating the organization of files within directories. + +## Installation +Install the `squadai_tools` package to use the DirectoryReadTool in your project. If you haven't added this package to your environment, you can easily install it with pip using the following command: + +```shell +pip install 'squadai[tools]' +``` + +This installs the latest version of the `squadai_tools` package, allowing access to the DirectoryReadTool and other utilities. + +## Example +The DirectoryReadTool is simple to use. The code snippet below shows how to set up and use the tool to list the contents of a specified directory: + +```python +from squadai_tools import DirectoryReadTool + +# Initialize the tool with the directory you want to explore +tool = DirectoryReadTool(directory='/path/to/your/directory') + +# Use the tool to list the contents of the specified directory +directory_contents = tool.run() +print(directory_contents) +``` + +This example demonstrates the essential steps to utilize the DirectoryReadTool effectively, highlighting its simplicity and user-friendly design. + +## Arguments +The DirectoryReadTool requires minimal configuration for use. The essential argument for this tool is as follows: + +- `directory`: A mandatory argument that specifies the path to the directory whose contents you wish to list. It accepts both absolute and relative paths, guiding the tool to the desired directory for content listing. + +The DirectoryReadTool provides a user-friendly and efficient way to list directory contents, making it an invaluable tool for managing and inspecting directory structures. +``` + +This revised documentation for the DirectoryReadTool maintains the structure and content requirements as outlined, with adjustments made for clarity, consistency, and adherence to the high-quality standards exemplified in the provided documentation example. diff --git a/squadai/squadai_tools/tools/directory_read_tool/directory_read_tool.py b/squadai/squadai_tools/tools/directory_read_tool/directory_read_tool.py new file mode 100644 index 0000000..8b569e5 --- /dev/null +++ b/squadai/squadai_tools/tools/directory_read_tool/directory_read_tool.py @@ -0,0 +1,38 @@ +import os +from typing import Optional, Type, Any +from pydantic.v1 import BaseModel, Field +from ..base_tool import BaseTool + +class FixedDirectoryReadToolSchema(BaseModel): + """Input for DirectoryReadTool.""" + pass + +class DirectoryReadToolSchema(FixedDirectoryReadToolSchema): + """Input for DirectoryReadTool.""" + directory: str = Field(..., description="Mandatory directory to list content") + +class DirectoryReadTool(BaseTool): + name: str = "List files in directory" + description: str = "A tool that can be used to recursively list a directory's content." + args_schema: Type[BaseModel] = DirectoryReadToolSchema + directory: Optional[str] = None + + def __init__(self, directory: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if directory is not None: + self.directory = directory + self.description = f"A tool that can be used to list {directory}'s content." + self.args_schema = FixedDirectoryReadToolSchema + self._generate_description() + + def _run( + self, + **kwargs: Any, + ) -> Any: + directory = kwargs.get('directory', self.directory) + if directory[-1] == "/": + directory = directory[:-1] + files_list = [f"{directory}/{(os.path.join(root, filename).replace(directory, '').lstrip(os.path.sep))}" for root, dirs, files in os.walk(directory) for filename in files] + files = "\n- ".join(files_list) + return f"File paths: \n-{files}" + diff --git a/squadai/squadai_tools/tools/directory_search_tool/README.md b/squadai/squadai_tools/tools/directory_search_tool/README.md new file mode 100644 index 0000000..f6f4ff7 --- /dev/null +++ b/squadai/squadai_tools/tools/directory_search_tool/README.md @@ -0,0 +1,55 @@ +# DirectorySearchTool + +## Description +This tool is designed to perform a semantic search for queries within the content of a specified directory. Utilizing the RAG (Retrieval-Augmented Generation) methodology, it offers a powerful means to semantically navigate through the files of a given directory. The tool can be dynamically set to search any directory specified at runtime or can be pre-configured to search within a specific directory upon initialization. + +## Installation +To start using the DirectorySearchTool, you need to install the squadai_tools package. Execute the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +The following examples demonstrate how to initialize the DirectorySearchTool for different use cases and how to perform a search: + +```python +from squadai_tools import DirectorySearchTool + +# To enable searching within any specified directory at runtime +tool = DirectorySearchTool() + +# Alternatively, to restrict searches to a specific directory +tool = DirectorySearchTool(directory='/path/to/directory') +``` + +## Arguments +- `directory` : This string argument specifies the directory within which to search. It is mandatory if the tool has not been initialized with a directory; otherwise, the tool will only search within the initialized directory. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = DirectorySearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/directory_search_tool/directory_search_tool.py b/squadai/squadai_tools/tools/directory_search_tool/directory_search_tool.py new file mode 100644 index 0000000..a062290 --- /dev/null +++ b/squadai/squadai_tools/tools/directory_search_tool/directory_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.loaders.directory_loader import DirectoryLoader +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedDirectorySearchToolSchema(BaseModel): + """Input for DirectorySearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the directory's content", + ) + + +class DirectorySearchToolSchema(FixedDirectorySearchToolSchema): + """Input for DirectorySearchTool.""" + + directory: str = Field(..., description="Mandatory directory you want to search") + + +class DirectorySearchTool(RagTool): + name: str = "Search a directory's content" + description: str = ( + "A tool that can be used to semantic search a query from a directory's content." + ) + args_schema: Type[BaseModel] = DirectorySearchToolSchema + + def __init__(self, directory: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if directory is not None: + self.add(directory) + self.description = f"A tool that can be used to semantic search a query the {directory} directory's content." + self.args_schema = FixedDirectorySearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["loader"] = DirectoryLoader(config=dict(recursive=True)) + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "directory" in kwargs: + self.add(kwargs["directory"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/docx_search_tool/README.md b/squadai/squadai_tools/tools/docx_search_tool/README.md new file mode 100644 index 0000000..5aad5d2 --- /dev/null +++ b/squadai/squadai_tools/tools/docx_search_tool/README.md @@ -0,0 +1,57 @@ +# DOCXSearchTool + +## Description +The DOCXSearchTool is a RAG tool designed for semantic searching within DOCX documents. It enables users to effectively search and extract relevant information from DOCX files using query-based searches. This tool is invaluable for data analysis, information management, and research tasks, streamlining the process of finding specific information within large document collections. + +## Installation +Install the squadai_tools package by running the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +The following example demonstrates initializing the DOCXSearchTool to search within any DOCX file's content or with a specific DOCX file path. + +```python +from squadai_tools import DOCXSearchTool + +# Initialize the tool to search within any DOCX file's content +tool = DOCXSearchTool() + +# OR + +# Initialize the tool with a specific DOCX file, so the agent can only search the content of the specified DOCX file +tool = DOCXSearchTool(docx='path/to/your/document.docx') +``` + +## Arguments +- `docx`: An optional file path to a specific DOCX document you wish to search. If not provided during initialization, the tool allows for later specification of any DOCX file's content path for searching. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = DOCXSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/docx_search_tool/docx_search_tool.py b/squadai/squadai_tools/tools/docx_search_tool/docx_search_tool.py new file mode 100644 index 0000000..b60dfd0 --- /dev/null +++ b/squadai/squadai_tools/tools/docx_search_tool/docx_search_tool.py @@ -0,0 +1,66 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedDOCXSearchToolSchema(BaseModel): + """Input for DOCXSearchTool.""" + docx: Optional[str] = Field(..., description="Mandatory docx path you want to search") + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the DOCX's content", + ) + +class DOCXSearchToolSchema(FixedDOCXSearchToolSchema): + """Input for DOCXSearchTool.""" + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the DOCX's content", + ) + +class DOCXSearchTool(RagTool): + name: str = "Search a DOCX's content" + description: str = ( + "A tool that can be used to semantic search a query from a DOCX's content." + ) + args_schema: Type[BaseModel] = DOCXSearchToolSchema + + def __init__(self, docx: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if docx is not None: + self.add(docx) + self.description = f"A tool that can be used to semantic search a query the {docx} DOCX's content." + self.args_schema = FixedDOCXSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.DOCX + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "docx" in kwargs: + self.add(kwargs["docx"]) + + def _run( + self, + **kwargs: Any, + ) -> Any: + search_query = kwargs.get('search_query') + if search_query is None: + search_query = kwargs.get('query') + + docx = kwargs.get("docx") + if docx is not None: + self.add(docx) + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/exa_tools/README.md b/squadai/squadai_tools/tools/exa_tools/README.md new file mode 100644 index 0000000..8fd9ef5 --- /dev/null +++ b/squadai/squadai_tools/tools/exa_tools/README.md @@ -0,0 +1,30 @@ +# EXASearchTool Documentation + +## Description +This tool is designed to perform a semantic search for a specified query from a text's content across the internet. It utilizes the `https://exa.ai/` API to fetch and display the most relevant search results based on the query provided by the user. + +## Installation +To incorporate this tool into your project, follow the installation instructions below: +```shell +pip install 'squadai[tools]' +``` + +## Example +The following example demonstrates how to initialize the tool and execute a search with a given query: + +```python +from squadai_tools import EXASearchTool + +# Initialize the tool for internet searching capabilities +tool = EXASearchTool() +``` + +## Steps to Get Started +To effectively use the `EXASearchTool`, follow these steps: + +1. **Package Installation**: Confirm that the `squadai[tools]` package is installed in your Python environment. +2. **API Key Acquisition**: Acquire a `https://exa.ai/` API key by registering for a free account at `https://exa.ai/`. +3. **Environment Configuration**: Store your obtained API key in an environment variable named `EXA_API_KEY` to facilitate its use by the tool. + +## Conclusion +By integrating the `EXASearchTool` into Python projects, users gain the ability to conduct real-time, relevant searches across the internet directly from their applications. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is streamlined and straightforward. diff --git a/squadai/squadai_tools/tools/exa_tools/exa_base_tool.py b/squadai/squadai_tools/tools/exa_tools/exa_base_tool.py new file mode 100644 index 0000000..f9af1d3 --- /dev/null +++ b/squadai/squadai_tools/tools/exa_tools/exa_base_tool.py @@ -0,0 +1,36 @@ +import os +from typing import Type +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class EXABaseToolToolSchema(BaseModel): + """Input for EXABaseTool.""" + search_query: str = Field(..., description="Mandatory search query you want to use to search the internet") + +class EXABaseTool(BaseTool): + name: str = "Search the internet" + description: str = "A tool that can be used to search the internet from a search_query" + args_schema: Type[BaseModel] = EXABaseToolToolSchema + search_url: str = "https://api.exa.ai/search" + n_results: int = None + headers: dict = { + "accept": "application/json", + "content-type": "application/json", + } + + def _parse_results(self, results): + stirng = [] + for result in results: + try: + stirng.append('\n'.join([ + f"Title: {result['title']}", + f"Score: {result['score']}", + f"Url: {result['url']}", + f"ID: {result['id']}", + "---" + ])) + except KeyError: + next + + content = '\n'.join(stirng) + return f"\nSearch results: {content}\n" diff --git a/squadai/squadai_tools/tools/exa_tools/exa_search_tool.py b/squadai/squadai_tools/tools/exa_tools/exa_search_tool.py new file mode 100644 index 0000000..30f77d1 --- /dev/null +++ b/squadai/squadai_tools/tools/exa_tools/exa_search_tool.py @@ -0,0 +1,28 @@ +import os +import requests +from typing import Any + +from .exa_base_tool import EXABaseTool + +class EXASearchTool(EXABaseTool): + def _run( + self, + **kwargs: Any, + ) -> Any: + search_query = kwargs.get('search_query') + if search_query is None: + search_query = kwargs.get('query') + + payload = { + "query": search_query, + "type": "magic", + } + + headers = self.headers.copy() + headers["x-api-key"] = os.environ['EXA_API_KEY'] + + response = requests.post(self.search_url, json=payload, headers=headers) + results = response.json() + if 'results' in results: + results = super()._parse_results(results['results']) + return results diff --git a/squadai/squadai_tools/tools/file_read_tool/README.md b/squadai/squadai_tools/tools/file_read_tool/README.md new file mode 100644 index 0000000..3fc47df --- /dev/null +++ b/squadai/squadai_tools/tools/file_read_tool/README.md @@ -0,0 +1,29 @@ +# FileReadTool + +## Description +The FileReadTool is a versatile component of the squadai_tools package, designed to streamline the process of reading and retrieving content from files. It is particularly useful in scenarios such as batch text file processing, runtime configuration file reading, and data importation for analytics. This tool supports various text-based file formats including `.txt`, `.csv`, `.json`, and adapts its functionality based on the file type, for instance, converting JSON content into a Python dictionary for easy use. + +## Installation +Install the squadai_tools package to use the FileReadTool in your projects: + +```shell +pip install 'squadai[tools]' +``` + +## Example +To get started with the FileReadTool: + +```python +from squadai_tools import FileReadTool + +# Initialize the tool to read any files the agents knows or lean the path for +file_read_tool = FileReadTool() + +# OR + +# Initialize the tool with a specific file path, so the agent can only read the content of the specified file +file_read_tool = FileReadTool(file_path='path/to/your/file.txt') +``` + +## Arguments +- `file_path`: The path to the file you want to read. It accepts both absolute and relative paths. Ensure the file exists and you have the necessary permissions to access it. \ No newline at end of file diff --git a/squadai/squadai_tools/tools/file_read_tool/file_read_tool.py b/squadai/squadai_tools/tools/file_read_tool/file_read_tool.py new file mode 100644 index 0000000..38aeeeb --- /dev/null +++ b/squadai/squadai_tools/tools/file_read_tool/file_read_tool.py @@ -0,0 +1,46 @@ +from typing import Optional, Type, Any +from pydantic.v1 import BaseModel, Field +from ..base_tool import BaseTool + + +class FixedFileReadToolSchema(BaseModel): + """Input for FileReadTool.""" + pass + + +class FileReadToolSchema(FixedFileReadToolSchema): + """Input for FileReadTool.""" + file_path: str = Field( + ..., + description="Mandatory file full path to read the file" + ) + + +class FileReadTool(BaseTool): + name: str = "Read a file's content" + description: str = "A tool that can be used to read a file's content." + args_schema: Type[BaseModel] = FileReadToolSchema + file_path: Optional[str] = None + + def __init__( + self, + file_path: Optional[str] = None, + **kwargs + ): + super().__init__(**kwargs) + if file_path is not None: + self.file_path = file_path + self.description = f"A tool that can be used to read {file_path}'s content." + self.args_schema = FixedFileReadToolSchema + self._generate_description() + + def _run( + self, + **kwargs: Any, + ) -> Any: + try: + file_path = kwargs.get('file_path', self.file_path) + with open(file_path, 'r') as file: + return file.read() + except Exception as e: + return f"Fail to read the file {file_path}. Error: {e}" diff --git a/squadai/squadai_tools/tools/file_writer_tool/README.md b/squadai/squadai_tools/tools/file_writer_tool/README.md new file mode 100644 index 0000000..53aab53 --- /dev/null +++ b/squadai/squadai_tools/tools/file_writer_tool/README.md @@ -0,0 +1,35 @@ +Here's the rewritten README for the `FileWriterTool`: + +# FileWriterTool Documentation + +## Description +The `FileWriterTool` is a component of the squadai_tools package, designed to simplify the process of writing content to files. It is particularly useful in scenarios such as generating reports, saving logs, creating configuration files, and more. This tool supports creating new directories if they don't exist, making it easier to organize your output. + +## Installation +Install the squadai_tools package to use the `FileWriterTool` in your projects: + +```shell +pip install 'squadai[tools]' +``` + +## Example +To get started with the `FileWriterTool`: + +```python +from squadai_tools import FileWriterTool + +# Initialize the tool +file_writer_tool = FileWriterTool() + +# Write content to a file in a specified directory +result = file_writer_tool._run('example.txt', 'This is a test content.', 'test_directory') +print(result) +``` + +## Arguments +- `filename`: The name of the file you want to create or overwrite. +- `content`: The content to write into the file. +- `directory` (optional): The path to the directory where the file will be created. Defaults to the current directory (`.`). If the directory does not exist, it will be created. + +## Conclusion +By integrating the `FileWriterTool` into your squads, the agents can execute the process of writing content to files and creating directories. This tool is essential for tasks that require saving output data, creating structured file systems, and more. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is straightforward and efficient. diff --git a/squadai/squadai_tools/tools/file_writer_tool/file_writer_tool.py b/squadai/squadai_tools/tools/file_writer_tool/file_writer_tool.py new file mode 100644 index 0000000..acc0016 --- /dev/null +++ b/squadai/squadai_tools/tools/file_writer_tool/file_writer_tool.py @@ -0,0 +1,39 @@ +import os +from typing import Optional, Type, Any + +from pydantic.v1 import BaseModel +from ..base_tool import BaseTool + +class FileWriterToolInput(BaseModel): + filename: str + content: str + directory: Optional[str] = None + overwrite: bool = False + +class FileWriterTool(BaseTool): + name: str = "File Writer Tool" + description: str = "A tool to write content to a specified file. Accepts filename, content, and optionally a directory path and overwrite flag as input." + args_schema: Type[BaseModel] = FileWriterToolInput + + def _run(self, **kwargs: Any) -> str: + try: + # Create the directory if it doesn't exist + if kwargs['directory'] and not os.path.exists(kwargs['directory']): + os.makedirs(kwargs['directory']) + + # Construct the full path + filepath = os.path.join(kwargs['directory'] or '', kwargs['filename']) + + # Check if file exists and overwrite is not allowed + if os.path.exists(filepath) and not kwargs['overwrite']: + return f"File {filepath} already exists and overwrite option was not passed." + + # Write content to the file + mode = 'w' if kwargs['overwrite'] else 'x' + with open(filepath, mode) as file: + file.write(kwargs['content']) + return f"Content successfully written to {filepath}" + except FileExistsError: + return f"File {filepath} already exists and overwrite option was not passed." + except Exception as e: + return f"An error occurred while writing to the file: {str(e)}" diff --git a/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/README.md b/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/README.md new file mode 100644 index 0000000..8cc2a54 --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/README.md @@ -0,0 +1,42 @@ +# FirecrawlCrawlWebsiteTool + +## Description + +[Firecrawl](https://firecrawl.dev) is a platform for crawling and convert any website into clean markdown or structured data. + +## Installation + +- Get an API key from [firecrawl.dev](https://firecrawl.dev) and set it in environment variables (`FIRECRAWL_API_KEY`). +- Install the [Firecrawl SDK](https://github.com/mendableai/firecrawl) along with `squadai[tools]` package: + +``` +pip install firecrawl-py 'squadai[tools]' +``` + +## Example + +Utilize the FirecrawlScrapeFromWebsiteTool as follows to allow your agent to load websites: + +```python +from squadai_tools import FirecrawlCrawlWebsiteTool + +tool = FirecrawlCrawlWebsiteTool(url='firecrawl.dev') +``` + +## Arguments + +- `api_key`: Optional. Specifies Firecrawl API key. Defaults is the `FIRECRAWL_API_KEY` environment variable. +- `url`: The base URL to start crawling from. +- `page_options`: Optional. + - `onlyMainContent`: Optional. Only return the main content of the page excluding headers, navs, footers, etc. + - `includeHtml`: Optional. Include the raw HTML content of the page. Will output a html key in the response. +- `crawler_options`: Optional. Options for controlling the crawling behavior. + - `includes`: Optional. URL patterns to include in the crawl. + - `exclude`: Optional. URL patterns to exclude from the crawl. + - `generateImgAltText`: Optional. Generate alt text for images using LLMs (requires a paid plan). + - `returnOnlyUrls`: Optional. If true, returns only the URLs as a list in the crawl status. Note: the response will be a list of URLs inside the data, not a list of documents. + - `maxDepth`: Optional. Maximum depth to crawl. Depth 1 is the base URL, depth 2 includes the base URL and its direct children, and so on. + - `mode`: Optional. The crawling mode to use. Fast mode crawls 4x faster on websites without a sitemap but may not be as accurate and shouldn't be used on heavily JavaScript-rendered websites. + - `limit`: Optional. Maximum number of pages to crawl. + - `timeout`: Optional. Timeout in milliseconds for the crawling operation. + diff --git a/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/firecrawl_crawl_website_tool.py b/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/firecrawl_crawl_website_tool.py new file mode 100644 index 0000000..64c9160 --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_crawl_website_tool/firecrawl_crawl_website_tool.py @@ -0,0 +1,38 @@ +from typing import Optional, Any, Type, Dict, List +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class FirecrawlCrawlWebsiteToolSchema(BaseModel): + url: str = Field(description="Website URL") + crawler_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for crawling") + page_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for page") + +class FirecrawlCrawlWebsiteTool(BaseTool): + name: str = "Firecrawl web crawl tool" + description: str = "Crawl webpages using Firecrawl and return the contents" + args_schema: Type[BaseModel] = FirecrawlCrawlWebsiteToolSchema + api_key: Optional[str] = None + firecrawl: Optional[Any] = None + + def __init__(self, api_key: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + try: + from firecrawl import FirecrawlApp # type: ignore + except ImportError: + raise ImportError( + "`firecrawl` package not found, please run `pip install firecrawl-py`" + ) + + self.firecrawl = FirecrawlApp(api_key=api_key) + + def _run(self, url: str, crawler_options: Optional[Dict[str, Any]] = None, page_options: Optional[Dict[str, Any]] = None): + if (crawler_options is None): + crawler_options = {} + if (page_options is None): + page_options = {} + + options = { + "crawlerOptions": crawler_options, + "pageOptions": page_options + } + return self.firecrawl.crawl_url(url, options) diff --git a/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/README.md b/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/README.md new file mode 100644 index 0000000..24767ad --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/README.md @@ -0,0 +1,38 @@ +# FirecrawlScrapeWebsiteTool + +## Description + +[Firecrawl](https://firecrawl.dev) is a platform for crawling and convert any website into clean markdown or structured data. + +## Installation + +- Get an API key from [firecrawl.dev](https://firecrawl.dev) and set it in environment variables (`FIRECRAWL_API_KEY`). +- Install the [Firecrawl SDK](https://github.com/mendableai/firecrawl) along with `squadai[tools]` package: + +``` +pip install firecrawl-py 'squadai[tools]' +``` + +## Example + +Utilize the FirecrawlScrapeWebsiteTool as follows to allow your agent to load websites: + +```python +from squadai_tools import FirecrawlScrapeWebsiteTool + +tool = FirecrawlScrapeWebsiteTool(url='firecrawl.dev') +``` + +## Arguments + +- `api_key`: Optional. Specifies Firecrawl API key. Defaults is the `FIRECRAWL_API_KEY` environment variable. +- `url`: The URL to scrape. +- `page_options`: Optional. + - `onlyMainContent`: Optional. Only return the main content of the page excluding headers, navs, footers, etc. + - `includeHtml`: Optional. Include the raw HTML content of the page. Will output a html key in the response. +- `extractor_options`: Optional. Options for LLM-based extraction of structured information from the page content + - `mode`: The extraction mode to use, currently supports 'llm-extraction' + - `extractionPrompt`: Optional. A prompt describing what information to extract from the page + - `extractionSchema`: Optional. The schema for the data to be extracted +- `timeout`: Optional. Timeout in milliseconds for the request + diff --git a/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/firecrawl_scrape_website_tool.py b/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/firecrawl_scrape_website_tool.py new file mode 100644 index 0000000..e84494f --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_scrape_website_tool/firecrawl_scrape_website_tool.py @@ -0,0 +1,42 @@ +from typing import Optional, Any, Type, Dict +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class FirecrawlScrapeWebsiteToolSchema(BaseModel): + url: str = Field(description="Website URL") + page_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for page scraping") + extractor_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for data extraction") + timeout: Optional[int] = Field(default=None, description="Timeout in milliseconds for the scraping operation. The default value is 30000.") + +class FirecrawlScrapeWebsiteTool(BaseTool): + name: str = "Firecrawl web scrape tool" + description: str = "Scrape webpages url using Firecrawl and return the contents" + args_schema: Type[BaseModel] = FirecrawlScrapeWebsiteToolSchema + api_key: Optional[str] = None + firecrawl: Optional[Any] = None + + def __init__(self, api_key: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + try: + from firecrawl import FirecrawlApp # type: ignore + except ImportError: + raise ImportError( + "`firecrawl` package not found, please run `pip install firecrawl-py`" + ) + + self.firecrawl = FirecrawlApp(api_key=api_key) + + def _run(self, url: str, page_options: Optional[Dict[str, Any]] = None, extractor_options: Optional[Dict[str, Any]] = None, timeout: Optional[int] = None): + if page_options is None: + page_options = {} + if extractor_options is None: + extractor_options = {} + if timeout is None: + timeout = 30000 + + options = { + "pageOptions": page_options, + "extractorOptions": extractor_options, + "timeout": timeout + } + return self.firecrawl.scrape_url(url, options) diff --git a/squadai/squadai_tools/tools/firecrawl_search_tool/README.md b/squadai/squadai_tools/tools/firecrawl_search_tool/README.md new file mode 100644 index 0000000..4f623e5 --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_search_tool/README.md @@ -0,0 +1,35 @@ +# FirecrawlSearchTool + +## Description + +[Firecrawl](https://firecrawl.dev) is a platform for crawling and convert any website into clean markdown or structured data. + +## Installation + +- Get an API key from [firecrawl.dev](https://firecrawl.dev) and set it in environment variables (`FIRECRAWL_API_KEY`). +- Install the [Firecrawl SDK](https://github.com/mendableai/firecrawl) along with `squadai[tools]` package: + +``` +pip install firecrawl-py 'squadai[tools]' +``` + +## Example + +Utilize the FirecrawlSearchTool as follows to allow your agent to load websites: + +```python +from squadai_tools import FirecrawlSearchTool + +tool = FirecrawlSearchTool(query='what is firecrawl?') +``` + +## Arguments + +- `api_key`: Optional. Specifies Firecrawl API key. Defaults is the `FIRECRAWL_API_KEY` environment variable. +- `query`: The search query string to be used for searching. +- `page_options`: Optional. Options for result formatting. + - `onlyMainContent`: Optional. Only return the main content of the page excluding headers, navs, footers, etc. + - `includeHtml`: Optional. Include the raw HTML content of the page. Will output a html key in the response. + - `fetchPageContent`: Optional. Fetch the full content of the page. +- `search_options`: Optional. Options for controlling the crawling behavior. + - `limit`: Optional. Maximum number of pages to crawl. \ No newline at end of file diff --git a/squadai/squadai_tools/tools/firecrawl_search_tool/firecrawl_search_tool.py b/squadai/squadai_tools/tools/firecrawl_search_tool/firecrawl_search_tool.py new file mode 100644 index 0000000..4344b5b --- /dev/null +++ b/squadai/squadai_tools/tools/firecrawl_search_tool/firecrawl_search_tool.py @@ -0,0 +1,38 @@ +from typing import Optional, Any, Type, Dict, List +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class FirecrawlSearchToolSchema(BaseModel): + query: str = Field(description="Search query") + page_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for result formatting") + search_options: Optional[Dict[str, Any]] = Field(default=None, description="Options for searching") + +class FirecrawlSearchTool(BaseTool): + name: str = "Firecrawl web search tool" + description: str = "Search webpages using Firecrawl and return the results" + args_schema: Type[BaseModel] = FirecrawlSearchToolSchema + api_key: Optional[str] = None + firecrawl: Optional[Any] = None + + def __init__(self, api_key: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + try: + from firecrawl import FirecrawlApp # type: ignore + except ImportError: + raise ImportError( + "`firecrawl` package not found, please run `pip install firecrawl-py`" + ) + + self.firecrawl = FirecrawlApp(api_key=api_key) + + def _run(self, query: str, page_options: Optional[Dict[str, Any]] = None, result_options: Optional[Dict[str, Any]] = None): + if (page_options is None): + page_options = {} + if (result_options is None): + result_options = {} + + options = { + "pageOptions": page_options, + "resultOptions": result_options + } + return self.firecrawl.search(query, options) diff --git a/squadai/squadai_tools/tools/github_search_tool/README.md b/squadai/squadai_tools/tools/github_search_tool/README.md new file mode 100644 index 0000000..c603ada --- /dev/null +++ b/squadai/squadai_tools/tools/github_search_tool/README.md @@ -0,0 +1,67 @@ +# GithubSearchTool + +## Description +The GithubSearchTool is a Retrieval Augmented Generation (RAG) tool specifically designed for conducting semantic searches within GitHub repositories. Utilizing advanced semantic search capabilities, it sifts through code, pull requests, issues, and repositories, making it an essential tool for developers, researchers, or anyone in need of precise information from GitHub. + +## Installation +To use the GithubSearchTool, first ensure the squadai_tools package is installed in your Python environment: + +```shell +pip install 'squadai[tools]' +``` + +This command installs the necessary package to run the GithubSearchTool along with any other tools included in the squadai_tools package. + +## Example +Here’s how you can use the GithubSearchTool to perform semantic searches within a GitHub repository: +```python +from squadai_tools import GithubSearchTool + +# Initialize the tool for semantic searches within a specific GitHub repository +tool = GithubSearchTool( + gh_token='...', + github_repo='https://github.com/example/repo', + content_types=['code', 'issue'] # Options: code, repo, pr, issue +) + +# OR + +# Initialize the tool for semantic searches within a specific GitHub repository, so the agent can search any repository if it learns about during its execution +tool = GithubSearchTool( + gh_token='...', + content_types=['code', 'issue'] # Options: code, repo, pr, issue +) +``` + +## Arguments +- `gh_token` : The GitHub token used to authenticate the search. This is a mandatory field and allows the tool to access the GitHub API for conducting searches. +- `github_repo` : The URL of the GitHub repository where the search will be conducted. This is a mandatory field and specifies the target repository for your search. +- `content_types` : Specifies the types of content to include in your search. You must provide a list of content types from the following options: `code` for searching within the code, `repo` for searching within the repository's general information, `pr` for searching within pull requests, and `issue` for searching within issues. This field is mandatory and allows tailoring the search to specific content types within the GitHub repository. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = GithubSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/github_search_tool/github_search_tool.py b/squadai/squadai_tools/tools/github_search_tool/github_search_tool.py new file mode 100644 index 0000000..2ec39c8 --- /dev/null +++ b/squadai/squadai_tools/tools/github_search_tool/github_search_tool.py @@ -0,0 +1,71 @@ +from typing import Any, List, Optional, Type + +from embedchain.loaders.github import GithubLoader +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedGithubSearchToolSchema(BaseModel): + """Input for GithubSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the github repo's content", + ) + + +class GithubSearchToolSchema(FixedGithubSearchToolSchema): + """Input for GithubSearchTool.""" + + github_repo: str = Field(..., description="Mandatory github you want to search") + content_types: List[str] = Field( + ..., + description="Mandatory content types you want to be included search, options: [code, repo, pr, issue]", + ) + + +class GithubSearchTool(RagTool): + name: str = "Search a github repo's content" + description: str = "A tool that can be used to semantic search a query from a github repo's content. This is not the GitHub API, but instead a tool that can provide semantic search capabilities." + summarize: bool = False + gh_token: str + args_schema: Type[BaseModel] = GithubSearchToolSchema + content_types: List[str] + + def __init__(self, github_repo: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if github_repo is not None: + self.add(repo=github_repo) + self.description = f"A tool that can be used to semantic search a query the {github_repo} github repo's content. This is not the GitHub API, but instead a tool that can provide semantic search capabilities." + self.args_schema = FixedGithubSearchToolSchema + self._generate_description() + + def add( + self, + repo: str, + content_types: List[str] | None = None, + **kwargs: Any, + ) -> None: + content_types = content_types or self.content_types + + kwargs["data_type"] = "github" + kwargs["loader"] = GithubLoader(config={"token": self.gh_token}) + super().add(f"repo:{repo} type:{','.join(content_types)}", **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "github_repo" in kwargs: + self.add( + repo=kwargs["github_repo"], content_types=kwargs.get("content_types") + ) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/json_search_tool/README.md b/squadai/squadai_tools/tools/json_search_tool/README.md new file mode 100644 index 0000000..4e56a61 --- /dev/null +++ b/squadai/squadai_tools/tools/json_search_tool/README.md @@ -0,0 +1,55 @@ +# JSONSearchTool + +## Description +This tool is used to perform a RAG search within a JSON file's content. It allows users to initiate a search with a specific JSON path, focusing the search operation within that particular JSON file. If the path is provided at initialization, the tool restricts its search scope to the specified JSON file, thereby enhancing the precision of search results. + +## Installation +Install the squadai_tools package by executing the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Below are examples demonstrating how to use the JSONSearchTool for searching within JSON files. You can either search any JSON content or restrict the search to a specific JSON file. + +```python +from squadai_tools import JSONSearchTool + +# Example 1: Initialize the tool for a general search across any JSON content. This is useful when the path is known or can be discovered during execution. +tool = JSONSearchTool() + +# Example 2: Initialize the tool with a specific JSON path, limiting the search to a particular JSON file. +tool = JSONSearchTool(json_path='./path/to/your/file.json') +``` + +## Arguments +- `json_path` (str): An optional argument that defines the path to the JSON file to be searched. This parameter is only necessary if the tool is initialized without a specific JSON path. Providing this argument restricts the search to the specified JSON file. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = JSONSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/json_search_tool/json_search_tool.py b/squadai/squadai_tools/tools/json_search_tool/json_search_tool.py new file mode 100644 index 0000000..930438c --- /dev/null +++ b/squadai/squadai_tools/tools/json_search_tool/json_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedJSONSearchToolSchema(BaseModel): + """Input for JSONSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the JSON's content", + ) + + +class JSONSearchToolSchema(FixedJSONSearchToolSchema): + """Input for JSONSearchTool.""" + + json_path: str = Field(..., description="Mandatory json path you want to search") + + +class JSONSearchTool(RagTool): + name: str = "Search a JSON's content" + description: str = ( + "A tool that can be used to semantic search a query from a JSON's content." + ) + args_schema: Type[BaseModel] = JSONSearchToolSchema + + def __init__(self, json_path: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if json_path is not None: + self.add(json_path) + self.description = f"A tool that can be used to semantic search a query the {json_path} JSON's content." + self.args_schema = FixedJSONSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.JSON + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "json_path" in kwargs: + self.add(kwargs["json_path"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/llamaindex_tool/README.md b/squadai/squadai_tools/tools/llamaindex_tool/README.md new file mode 100644 index 0000000..3200af1 --- /dev/null +++ b/squadai/squadai_tools/tools/llamaindex_tool/README.md @@ -0,0 +1,53 @@ +# LlamaIndexTool Documentation + +## Description +This tool is designed to be a general wrapper around LlamaIndex tools and query engines, enabling you to leverage LlamaIndex resources +in terms of RAG/agentic pipelines as tools to plug into SquadAI agents. + +## Installation +To incorporate this tool into your project, follow the installation instructions below: +```shell +pip install 'squadai[tools]' +``` + +## Example +The following example demonstrates how to initialize the tool and execute a search with a given query: + +```python +from squadai_tools import LlamaIndexTool + +# Initialize the tool from a LlamaIndex Tool + +## Example 1: Initialize from FunctionTool +from llama_index.core.tools import FunctionTool + +your_python_function = lambda ...: ... +og_tool = FunctionTool.from_defaults(your_python_function, name="", description='') +tool = LlamaIndexTool.from_tool(og_tool) + +## Example 2: Initialize from LlamaHub Tools +from llama_index.tools.wolfram_alpha import WolframAlphaToolSpec +wolfram_spec = WolframAlphaToolSpec(app_id="") +wolfram_tools = wolfram_spec.to_tool_list() +tools = [LlamaIndexTool.from_tool(t) for t in wolfram_tools] + + +# Initialize Tool from a LlamaIndex Query Engine + +## NOTE: LlamaIndex has a lot of query engines, define whatever query engine you want +query_engine = index.as_query_engine() +query_tool = LlamaIndexTool.from_query_engine( + query_engine, + name="Uber 2019 10K Query Tool", + description="Use this tool to lookup the 2019 Uber 10K Annual Report" +) + +``` + +## Steps to Get Started +To effectively use the `LlamaIndexTool`, follow these steps: + +1. **Install SquadAI**: Confirm that the `squadai[tools]` package is installed in your Python environment. +2. **Install and use LlamaIndex**: Follow LlamaIndex documentation (https://docs.llamaindex.ai/) to setup a RAG/agent pipeline. + + diff --git a/squadai/squadai_tools/tools/llamaindex_tool/llamaindex_tool.py b/squadai/squadai_tools/tools/llamaindex_tool/llamaindex_tool.py new file mode 100644 index 0000000..ca0ca33 --- /dev/null +++ b/squadai/squadai_tools/tools/llamaindex_tool/llamaindex_tool.py @@ -0,0 +1,84 @@ +import os +import json +import requests + +from typing import Type, Any, cast, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class LlamaIndexTool(BaseTool): + """Tool to wrap LlamaIndex tools/query engines.""" + llama_index_tool: Any + + def _run( + self, + *args: Any, + **kwargs: Any, + ) -> Any: + """Run tool.""" + from llama_index.core.tools import BaseTool as LlamaBaseTool + tool = cast(LlamaBaseTool, self.llama_index_tool) + return tool(*args, **kwargs) + + @classmethod + def from_tool( + cls, + tool: Any, + **kwargs: Any + ) -> "LlamaIndexTool": + from llama_index.core.tools import BaseTool as LlamaBaseTool + + if not isinstance(tool, LlamaBaseTool): + raise ValueError(f"Expected a LlamaBaseTool, got {type(tool)}") + tool = cast(LlamaBaseTool, tool) + + if tool.metadata.fn_schema is None: + raise ValueError("The LlamaIndex tool does not have an fn_schema specified.") + args_schema = cast(Type[BaseModel], tool.metadata.fn_schema) + + return cls( + name=tool.metadata.name, + description=tool.metadata.description, + args_schema=args_schema, + llama_index_tool=tool, + **kwargs + ) + + + @classmethod + def from_query_engine( + cls, + query_engine: Any, + name: Optional[str] = None, + description: Optional[str] = None, + return_direct: bool = False, + **kwargs: Any + ) -> "LlamaIndexTool": + from llama_index.core.query_engine import BaseQueryEngine + from llama_index.core.tools import QueryEngineTool + + if not isinstance(query_engine, BaseQueryEngine): + raise ValueError(f"Expected a BaseQueryEngine, got {type(query_engine)}") + + # NOTE: by default the schema expects an `input` variable. However this + # confuses squadAI so we are renaming to `query`. + class QueryToolSchema(BaseModel): + """Schema for query tool.""" + query: str = Field(..., description="Search query for the query tool.") + + # NOTE: setting `resolve_input_errors` to True is important because the schema expects `input` but we are using `query` + query_engine_tool = QueryEngineTool.from_defaults( + query_engine, + name=name, + description=description, + return_direct=return_direct, + resolve_input_errors=True, + ) + # HACK: we are replacing the schema with our custom schema + query_engine_tool.metadata.fn_schema = QueryToolSchema + + return cls.from_tool( + query_engine_tool, + **kwargs + ) + diff --git a/squadai/squadai_tools/tools/mdx_seach_tool/README.md b/squadai/squadai_tools/tools/mdx_seach_tool/README.md new file mode 100644 index 0000000..cec796e --- /dev/null +++ b/squadai/squadai_tools/tools/mdx_seach_tool/README.md @@ -0,0 +1,57 @@ +# MDXSearchTool + +## Description +The MDX Search Tool, a key component of the `squadai_tools` package, is designed for advanced market data extraction, offering invaluable support to researchers and analysts requiring immediate market insights in the AI sector. With its ability to interface with various data sources and tools, it streamlines the process of acquiring, reading, and organizing market data efficiently. + +## Installation +To utilize the MDX Search Tool, ensure the `squadai_tools` package is installed. If not already present, install it using the following command: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Configuring and using the MDX Search Tool involves setting up environment variables and utilizing the tool within a squadAI project for market research. Here's a simple example: + +```python +from squadai_tools import MDXSearchTool + +# Initialize the tool so the agent can search any MDX content if it learns about during its execution +tool = MDXSearchTool() + +# OR + +# Initialize the tool with a specific MDX file path for exclusive search within that document +tool = MDXSearchTool(mdx='path/to/your/document.mdx') +``` + +## Arguments +- mdx: **Optional** The MDX path for the search. Can be provided at initialization + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = MDXSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/mdx_seach_tool/mdx_search_tool.py b/squadai/squadai_tools/tools/mdx_seach_tool/mdx_search_tool.py new file mode 100644 index 0000000..6957214 --- /dev/null +++ b/squadai/squadai_tools/tools/mdx_seach_tool/mdx_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedMDXSearchToolSchema(BaseModel): + """Input for MDXSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the MDX's content", + ) + + +class MDXSearchToolSchema(FixedMDXSearchToolSchema): + """Input for MDXSearchTool.""" + + mdx: str = Field(..., description="Mandatory mdx path you want to search") + + +class MDXSearchTool(RagTool): + name: str = "Search a MDX's content" + description: str = ( + "A tool that can be used to semantic search a query from a MDX's content." + ) + args_schema: Type[BaseModel] = MDXSearchToolSchema + + def __init__(self, mdx: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if mdx is not None: + self.add(mdx) + self.description = f"A tool that can be used to semantic search a query the {mdx} MDX's content." + self.args_schema = FixedMDXSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.MDX + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "mdx" in kwargs: + self.add(kwargs["mdx"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/multion_tool/README.md b/squadai/squadai_tools/tools/multion_tool/README.md new file mode 100644 index 0000000..e58be71 --- /dev/null +++ b/squadai/squadai_tools/tools/multion_tool/README.md @@ -0,0 +1,54 @@ +# MultiOnTool Documentation + +## Description +The MultiOnTool, integrated within the squadai_tools package, empowers SquadAI agents with the capability to navigate and interact with the web through natural language instructions. Leveraging the Multion API, this tool facilitates seamless web browsing, making it an essential asset for projects requiring dynamic web data interaction. + +## Installation +Ensure the `squadai[tools]` package is installed in your environment to use the MultiOnTool. If it's not already installed, you can add it using the command below: +```shell +pip install 'squadai[tools]' +``` + +## Example +The following example demonstrates how to initialize the tool and execute a search with a given query: + +```python +from squadai import Agent, Task, Squad +from squadai_tools import MultiOnTool + +# Initialize the tool from a MultiOn Tool +multion_tool = MultiOnTool(api_key= "YOUR_MULTION_API_KEY", local=False) + +Browser = Agent( + role="Browser Agent", + goal="control web browsers using natural language ", + backstory="An expert browsing agent.", + tools=[multion_remote_tool], + verbose=True, +) + +# example task to search and summarize news +browse = Task( + description="Summarize the top 3 trending AI News headlines", + expected_output="A summary of the top 3 trending AI News headlines", + agent=Browser, +) + +squad = Squad(agents=[Browser], tasks=[browse]) + +squad.kickoff() +``` + +## Arguments + +- `api_key`: Specifies Browserbase API key. Defaults is the `BROWSERBASE_API_KEY` environment variable. +- `local`: Use the local flag set as "true" to run the agent locally on your browser. Make sure the multion browser extension is installed and API Enabled is checked. +- `max_steps`: Optional. Set the max_steps the multion agent can take for a command + +## Steps to Get Started +To effectively use the `MultiOnTool`, follow these steps: + +1. **Install SquadAI**: Confirm that the `squadai[tools]` package is installed in your Python environment. +2. **Install and use MultiOn**: Follow MultiOn documentation for installing the MultiOn Browser Extension (https://docs.multion.ai/learn/browser-extension). +3. **Enable API Usage**: Click on the MultiOn extension in the extensions folder of your browser (not the hovering MultiOn icon on the web page) to open the extension configurations. Click the API Enabled toggle to enable the API + diff --git a/squadai/squadai_tools/tools/multion_tool/example.py b/squadai/squadai_tools/tools/multion_tool/example.py new file mode 100644 index 0000000..db8ae20 --- /dev/null +++ b/squadai/squadai_tools/tools/multion_tool/example.py @@ -0,0 +1,29 @@ +import os + +from squadai import Agent, Squad, Task +from multion_tool import MultiOnTool + +os.environ["GROQ_API_KEY"] = "Your Key" + +multion_browse_tool = MultiOnTool(api_key="Your Key") + +# Create a new agent +Browser = Agent( + role="Browser Agent", + goal="control web browsers using natural language ", + backstory="An expert browsing agent.", + tools=[multion_browse_tool], + verbose=True, +) + +# Define tasks +browse = Task( + description="Summarize the top 3 trending AI News headlines", + expected_output="A summary of the top 3 trending AI News headlines", + agent=Browser, +) + + +squad = Squad(agents=[Browser], tasks=[browse]) + +squad.kickoff() diff --git a/squadai/squadai_tools/tools/multion_tool/multion_tool.py b/squadai/squadai_tools/tools/multion_tool/multion_tool.py new file mode 100644 index 0000000..3976179 --- /dev/null +++ b/squadai/squadai_tools/tools/multion_tool/multion_tool.py @@ -0,0 +1,65 @@ +"""Multion tool spec.""" + +from typing import Any, Optional + +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class MultiOnTool(BaseTool): + """Tool to wrap MultiOn Browse Capabilities.""" + + name: str = "Multion Browse Tool" + description: str = """Multion gives the ability for LLMs to control web browsers using natural language instructions. + If the status is 'CONTINUE', reissue the same instruction to continue execution + """ + multion: Optional[Any] = None + session_id: Optional[str] = None + local: bool = False + max_steps: int = 3 + + def __init__( + self, + api_key: Optional[str] = None, + local: bool = False, + max_steps: int = 3, + **kwargs, + ): + super().__init__(**kwargs) + try: + from multion.client import MultiOn # type: ignore + except ImportError: + raise ImportError( + "`multion` package not found, please run `pip install multion`" + ) + self.session_id = None + self.local = local + self.multion = MultiOn(api_key=api_key) + self.max_steps = max_steps + + def _run( + self, + cmd: str, + *args: Any, + **kwargs: Any, + ) -> str: + """ + Run the Multion client with the given command. + + Args: + cmd (str): The detailed and specific natural language instructrion for web browsing + + *args (Any): Additional arguments to pass to the Multion client + **kwargs (Any): Additional keyword arguments to pass to the Multion client + """ + + browse = self.multion.browse( + cmd=cmd, + session_id=self.session_id, + local=self.local, + max_steps=self.max_steps, + *args, + **kwargs, + ) + self.session_id = browse.session_id + + return browse.message + "\n\n STATUS: " + browse.status diff --git a/squadai/squadai_tools/tools/mysql_search_tool/README.md b/squadai/squadai_tools/tools/mysql_search_tool/README.md new file mode 100644 index 0000000..30c66e3 --- /dev/null +++ b/squadai/squadai_tools/tools/mysql_search_tool/README.md @@ -0,0 +1,56 @@ +# MySQLSearchTool + +## Description +This tool is designed to facilitate semantic searches within MySQL database tables. Leveraging the RAG (Retrieve and Generate) technology, the MySQLSearchTool provides users with an efficient means of querying database table content, specifically tailored for MySQL databases. It simplifies the process of finding relevant data through semantic search queries, making it an invaluable resource for users needing to perform advanced queries on extensive datasets within a MySQL database. + +## Installation +To install the `squadai_tools` package and utilize the MySQLSearchTool, execute the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Below is an example showcasing how to use the MySQLSearchTool to conduct a semantic search on a table within a MySQL database: + +```python +from squadai_tools import MySQLSearchTool + +# Initialize the tool with the database URI and the target table name +tool = MySQLSearchTool(db_uri='mysql://user:password@localhost:3306/mydatabase', table_name='employees') + +``` + +## Arguments +The MySQLSearchTool requires the following arguments for its operation: + +- `db_uri`: A string representing the URI of the MySQL database to be queried. This argument is mandatory and must include the necessary authentication details and the location of the database. +- `table_name`: A string specifying the name of the table within the database on which the semantic search will be performed. This argument is mandatory. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = MySQLSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/mysql_search_tool/mysql_search_tool.py b/squadai/squadai_tools/tools/mysql_search_tool/mysql_search_tool.py new file mode 100644 index 0000000..372a02f --- /dev/null +++ b/squadai/squadai_tools/tools/mysql_search_tool/mysql_search_tool.py @@ -0,0 +1,44 @@ +from typing import Any, Type + +from embedchain.loaders.mysql import MySQLLoader +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class MySQLSearchToolSchema(BaseModel): + """Input for MySQLSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory semantic search query you want to use to search the database's content", + ) + + +class MySQLSearchTool(RagTool): + name: str = "Search a database's table content" + description: str = "A tool that can be used to semantic search a query from a database table's content." + args_schema: Type[BaseModel] = MySQLSearchToolSchema + db_uri: str = Field(..., description="Mandatory database URI") + + def __init__(self, table_name: str, **kwargs): + super().__init__(**kwargs) + self.add(table_name) + self.description = f"A tool that can be used to semantic search a query the {table_name} database table's content." + self._generate_description() + + def add( + self, + table_name: str, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = "mysql" + kwargs["loader"] = MySQLLoader(config=dict(url=self.db_uri)) + super().add(f"SELECT * FROM {table_name};", **kwargs) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query) diff --git a/squadai/squadai_tools/tools/nl2sql/README.md b/squadai/squadai_tools/tools/nl2sql/README.md new file mode 100644 index 0000000..9385827 --- /dev/null +++ b/squadai/squadai_tools/tools/nl2sql/README.md @@ -0,0 +1,74 @@ +# NL2SQL Tool + +## Description + +This tool is used to convert natural language to SQL queries. When passsed to the agent it will generate queries and then use them to interact with the database. + +This enables multiple workflows like having an Agent to access the database fetch information based on the goal and then use the information to generate a response, report or any other output. Along with that proivdes the ability for the Agent to update the database based on its goal. + +**Attention**: Make sure that the Agent has access to a Read-Replica or that is okay for the Agent to run insert/update queries on the database. + +## Requirements + +- SqlAlchemy +- Any DB compatible library (e.g. psycopg2, mysql-connector-python) + +## Installation +Install the squadai_tools package +```shell +pip install 'squadai[tools]' +``` + +## Usage + +In order to use the NL2SQLTool, you need to pass the database URI to the tool. The URI should be in the format `dialect+driver://username:password@host:port/database`. + + +```python +from squadai_tools import NL2SQLTool + +# psycopg2 was installed to run this example with PostgreSQL +nl2sql = NL2SQLTool(db_uri="postgresql://example@localhost:5432/test_db") + +@agent +def researcher(self) -> Agent: + return Agent( + config=self.agents_config["researcher"], + allow_delegation=False, + tools=[nl2sql] + ) +``` + +## Example + +The primary task goal was: + +"Retrieve the average, maximum, and minimum monthly revenue for each city, but only include cities that have more than one user. Also, count the number of user in each city and sort the results by the average monthly revenue in descending order" + +So the Agent tried to get information from the DB, the first one is wrong so the Agent tries again and gets the correct information and passes to the next agent. + +![alt text](images/image-2.png) +![alt text](images/image-3.png) + + +The second task goal was: + +"Review the data and create a detailed report, and then create the table on the database with the fields based on the data provided. +Include information on the average, maximum, and minimum monthly revenue for each city, but only include cities that have more than one user. Also, count the number of users in each city and sort the results by the average monthly revenue in descending order." + +Now things start to get interesting, the Agent generates the SQL query to not only create the table but also insert the data into the table. And in the end the Agent still returns the final report which is exactly what was in the database. + +![alt text](images/image-4.png) +![alt text](images/image-5.png) + +![alt text](images/image-9.png) +![alt text](images/image-7.png) + + +This is a simple example of how the NL2SQLTool can be used to interact with the database and generate reports based on the data in the database. + +The Tool provides endless possibilities on the logic of the Agent and how it can interact with the database. + +``` + DB -> Agent -> ... -> Agent -> DB +``` diff --git a/squadai/squadai_tools/tools/nl2sql/images/image-2.png b/squadai/squadai_tools/tools/nl2sql/images/image-2.png new file mode 100644 index 0000000000000000000000000000000000000000..b3844f0ddc25e4d407143b7d886f4b3c2ccebc55 GIT binary patch literal 84676 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
mokaBRcn6GcpE7lcTv# zzfr}G`O~zzBHE~&YSZu9M^?75A*U9@mALnL!ZS9F=7NygHTD=}<-aVoy(rusX^T~g z`;+mRYUKOrSSLDnScI6OPhIbXhNwP6;(XeN?>feHle$6?wbgCuKa%0Ey6U-nHCZ$t z&zU&V=Zw8Y9r}%bU(<6co>@YFeQV((E_qwBTGXy3Pbx*9rop5U=9sWA`e{2C1l8_# zJNPP9fFuOfK{kV`tn<$wH81Q5=&G~)bfJ9}n+7omZzHZ&G_BShEy^T;i zZI`$EwVPqpkvea`iN7Yr8jy!?)e+$gJwMCi^J6qy?*5NgVrKU6W3@*#+?&)Yyf5qW zSaeed0gsN%HuKxwi^Y~%P?tCb0;CYFNuG?S&CwWGKVJ4%1KSjY53P?jIkQl?VmOhq zQ=?4Piyc&-8u>TmFLm}SQw3;aHupv)55D5fT;6|HlB)8qm@}HJ&HKeX4pIw}@xxgD zN)?4-z~K`dQy^|Nu{4u6l9UhvyP?Vl*9)r+)?My-bXxpTXMLX| zsg)OQ_m&lS121JjXzQ=FZaS{fa38W7PjP8hk6LFM!O?=?)

sCkMaGpo(tFx)`qEmOh!`BHrJ7~j?g*mWNr zIka<_yh`j+{ift3XQR{vx3R;%l0(7@;v9SbzQVTnC~~6q z1KmyOYHc@+QBZa4FHeD58Vr5H3^sP!grUMIYeDrS*qqjx`4a=457#pzWFSr)w~Xi` z#Txc)n^%N1>hb=8r<0nq5<|^(Orl%cMKrDakRYjd8&gB(8~SX$hb9L`-zKd;3Eh!= zlm3m{YyYkOTj-|V*wqqEaN5aRZb1&CNQNKFJmCRLX(&DKuwsi2VuwO@S}vU95J;h> z{FZZplz~BI_JsaQ>nE5pL+z?8VyZNWFp9d5t{9ka`o~B}Bl49lcoLfJB4Yy~Z++8j)Z`5(+)2pJ2;(+yo^Z!ebj*uj<|!x9+d23N?Ctf% z$&ipS>W1m*!umi5QIh9r7kblZqGmZmDto@)o)VHSaG%)clZEx;@Q?}Orh0J=GmR}t zU*|g{G0b!DsxZRj7(C>Zrg4c@b74XQqq($PG*uye^4&?@qut;0G?SD%kUoVzhXDqDYO^GRxUB`g%E!`}S3 ztyR8}WnWOS@WuXG+9P_LeV_XNjvTU`tV?gZw$s6Fx#B-oVXXk9O5sk?sB1n1qYsRJ zKb&=6t?oG|WXUTfuH2fd>GUBXUA+@10Eq}OtPO~gS?PbT2-c_5;g)|+fVI%YZAGRb zlx>j=M3&mqa2+?yy9m;4W!ZVFNbb(;=h66+hEObuq{G7uXO)bpU@ONJi^!&&*JkaG zboa737d&uH2ArW0e>yDZS%g#<<+m@=vFuVKvpe0w9R>(&nPv^&N{W|q#zkyyoq(}n z6Xif(6OG85f8@W%YW+dUxU)hxgBKoa0Y}Gj2$vb>YQP_7g#*af5J+J7XH)){e3ud+ z^(_C$7ae{ixzHCN_{xnm%Fd8!Rn*CgYnIeX=aVK6JA<#XdcI+U-F?lax;RE=`~AYOX&f(m z)&l=3+t0L<*dupJ*9=#t0m^$Q3NOx`{uWqp1|F1QOG zjPOZ2czyn0)j2xmYKBe~NCm%ST!CD>mQZ1ic9FzAb>Je)+;Al8t|M?(yvv@NkqHK8 zCl)PXrQMY~-k+Q+!QB^5PyX2}b?Lxwob4#)tnK1usAyt+CZ;;9epRRT+KfX#r2&WZ zGhK;s>B9|ARpQv&Fikd0;xG9Q$vz=o_mA|V@;bP1wh#u8O#>)tAYB5x8@h*WUYbs; zaZ_l)vr;7D)`lX4PdM-VEq2@7XN9Jf!4D{K@LFuI5j^ggU0dDZlg}eZM3Eb_`g9Wg zOCLLXw9^)+X4ldXP^NGXjk444Bq^cH(}GkP(Mz4~2yIO!906z>^Su{nF-GF#^zIntolZ_hnF?9xJXTb)5MTN*g&VrAdxoDz;&KE|KNu}`@Y)lfoV2_)X|2>*$i zCqq(+)Vm3ilZV}MF?nv#!^Gi1k5&s?QNTUEL z=@N#-?-+}B(=f`&mLY09m~@nA%_fwSqH#r>J3CN9M0mhZo8U~IOz8u{XIQr$<0cK=w;Fg+b`7YHLS4GH3C}Q zKe9tE0X(L^tkm0Y7F!vQ+U>^^EvBucF9Ju7DR(@^>b+_hYtW z2CM$O=CH&b_sFi-q7)Ph7R&w%8j6{mU?@(JiN_ocpcK8HQNlT@lWGb7U6R6Hgr$XU z8g!t1Z&fIxcor}SvgTn0b$%y+Hq%!$o6xib&lHR=Xe60cYx=omV9g_CAZ$cNIHf+hYOeQehK5Fh+y^6Or=mPBH2t z-pSa0Q(W1h$Vw9HL#09tD0Ptzb&xNKnTUrtr~U+)GXaLWv9jdl1fN5F(+X5@5W zF9kVDgspqoMUsMR_@E&2eD>u7u1|Uka7b#K2P$d-s0eoM9D9*i-8h&P!Hb?omKpY& zOI9NLxs$i=CTmi1q;_0kHrnlw;c(dqPFXb?^hGh!kOa#^yS>I_ zx0qABMAW!TH(pdMp*CR*Fgspq{$!Maapwv-lB<2|y5d}RYJTqIqFOdNIeO~{+iN2o z(-4P0eX7;VvqM%u$?XGYJoVgVn77nzgC>~o(*K9Kfa8>$WrVdw3`d;Up@BLAo^ zq+u6I#msMtYRP*7J*(=2cVp_p8?j$3(KQdXz8f8OBJtyFETl-ak zms(BUo+giO77F$<-11DtpC{8j0nh>XM7Fq+a|iYVXuPQfDumVC8 zV1U6p(Q#-kdf+5!&sWF#=`bt3;zSxdXEq+!q%q-d3otEe+_-e2xSnE#Iqm(0J4X>F zoFR*DAGeHl%nHQz?&wdt#WHY8ADJvCbhc@`U?$=J0|EcRa8Mwd8V9DIZYCN+gmm{;%pC5}p=np#w!;@x<6|^rjPVnVn zC#q?zFiP!mjZTz(R&QVzFy4An|KI=|8!a1Fp>sf-Qc2R9&?>4ZaJKiRs}*}A6s{Zh zF7QH&Io9(j!M6r^5D|=fcKK{f$mW@p@8>JD!w1~ot}E>a+`RN}LS7LgY9sZfGLRo)Rw>gpt#VSk9S+(nOkzHTQ*;_BO%NytzO9K!>e?)p#} z-lPi?IOU;iUR)rlPCI-hk@=0I{BVtKdzWLBp_@aTg-1L4k;iG@?d8ol57idYuS%68 z?+HKy%pH%%O$anxK|}(Y@xMVn9^ZLMl)^Fh*ZBiincj>LTfQf4y-dDByKp0|A-Z;9K0=Q%u%*~*v@ zri2fiZ4KlPNuq1>ewjR?e6wcEYkn12;)VG-@vMR54G;gjsEC9`90>?KEsQSlG)s7!3fli}x&ezZ4J9V7-5 z`m!W0?iyKI%~qeTHhSJ-3;#mWd57`(?+4L)&n~W=$y+GkpKrmPjDMF^D-v+yE7u$4 z<9O0IK`%e-*#(3G|M$C#^x`Xmv&*(Y^Ndr%^Q}}F?c8aV2|@j> zneF`*3qX>f?xpd)upP`fqa5O5ObA7Ca4awHl-RtIN@~|ih;L;x$bi?ZhG=BX;x0o) z^Xf_ODi(fPYI1|+`ObxAzG9aHI+*BQ1D+3oJ3$2SrRSLb4P7+qolegkd)eh~Ng1*h zgM}D_wnAW?J%j^Jo2|$GdAlchboe$b$XBzr-?i6#f$=+uUK>g4z74p=G7V<4KaE+R zoY?Gdi?-0A!(P_#?pJ}AZxl3kJ6Q~`dH~5N!liDc3NSumT(#tgGVKXEF}WU-!M*%3m@&) z2&@52@t@MSNg$>vnnU*Y1Dh1^kA|N-`#l~}$qoAnphHY~aj@b`$UADD_MMjc77>Y8 z=;);V&K-4d;!U3I=u~T5LO%+Bb`x5ykD#UiJKi(Yo-FxtbSAa8!;=4Ka;q{EaC|PG zP5ZGj;ivg4;1xa>~uwr_j-(u@P8Hrc|E6@h7^gFb51av^;j~&yx4;x?(!0FgUCpXZTN|s-)HTX0 zRI8$FdMxwD`wYP94s@Q(QukNi$>k1|eZsXcD|Rii;7c&1c_`{0V`_zj(LyuUW%SVQ)WOYZ-}0V)1~u z=YHg9G+Vp{LVqxTSPvX3B=d_vGCj*s{?F+So#thSt4j3t-7%_zh=&ra^z_-|w4g-! zEYEl>J6|nFhA%D+)XMg(5$q1^$C>U;`dkl9Zh>2+97|p5!?Vy13;Gmk63bUWZs`u8 zM;wJOMFqcHtZ+)5YqamH#u&iB-6Oj^%8YFvLtJ0v~Xn;A4E~e|@Tx?;Js{AiekL-s*AEP8|QcNNL3bk<$@%AhTMg z4bJsQ0p>wpk9rO9GDGmF1c|lTBxZs3WYL4SS7Mk}i*Hh;y_DS2E-6%bGN#R35aVVb z>slgKu^>)RJgJmn>sgQ?ow0AarRURRjwGmCCZ3~s4hI@F?yK}fj<~6Bd>A<;*E?Rj zXBf(51!4N72oH`JB9R1YHv_VzXu?LgpJvxxpNd)#E)ch5QN&WXNNo$AP|X}o8Bk?0 zj=qE>Z7~3YlUMXm$GT}X*vsxlJAuvg1}Xk)@poxOeLCo3!nItHf}E4c&oB3`Ol_oF zde&^1PC1rsh>bDc#A}9GCV5eQhM>Dcq4jivSZNn3N8z$bm?39rN zq-ODO0?SHrFmobvu?7GM$Mqb3{9RIk=>NH-R0uS9jMef3(V1|wqk*CUuu(mhZC9tJ z8>Wl@oG3ZQcQi2jyOuUo+z_UDjiH2Anh>s1*hysTN_F028`q(eIBYLP5XKrEN!au* z5QBf!plJEH88y?{+{vSgxn+niI;6Dzno;`wEiic;-FoUe-ByGWN>P}zDqH$D&i3^a z{EEkg5}hGOP%5LEwXtFad{xVdmGgz^Qr80AYr=DqoC(aj)%&yP6DNjAxpN5mK z8D+{5x1j!zncMGw>x0QjlqH$WQA33{5BO!G5s=lO$LPS|L$zxh>7y~`yY`L<&r`I( zqRIYiAuTlMNb(D6@8Btwb`mUZA@5$dq{s=3Ve<#^&2#Wc;&JEiWMqI^^+a1>l^o_ zi{OvSTq3%=%YAsmKN~!kY>DfDW=Q&3E@;iJ>3#jdrWO)2YymFDOod09@gIFBNuZpn zyET+`U<<=Ee~s4~N4=A1o`>D+4f9E7i0261=|$1oFW!b(jbYTt_-eQDAr32G7z(A!8nQ1R(v zrgVRzb^@jS(TX_C+ZX-K;?h~Tt~5ai!t*9Y9+}hpe>i&!raIeYYd67y6C}6ECgcXubay9Rf6cfNSLUy z7RTaUFWx_|-Y)O@^DYLI-D_5ikL*)7MeE zonpt`qTTa-+_4&-FFij*=tEFVZS??l>SjVJ7qcY{5__#haue#KOAw_c3~icrY62#E2B8XqT`uiK~y^~+GrdB=bR~sN|o;?V0rl<*%`MgWN?<; zW{F6%dKv#I4HLs_rRrl{d$rUZD~;-+>+Yd-75Cdh_+9UB9(eaf-+{|fR5zXD7tq_f z$+^(e7_-@(X^BysLdvF!c*+9hsX0VZ<*Nx={P~(uSKEkB5>>$inSK#`#r?Q)J5`bR zjASM*P%GVb3Y?in2EJr-513^*rxZI0$;*UX@?ovp7S>Lbn zL&714odd`QQ0|a_(GS3T8d*kz8zET^G*jCA(vC7APb4ef@BMsR3D>7PSNXbjRV&F@ z6@R7G;)zwZ-1uqJ!ze~12~6pCx3U)3y<8_#d9=!yAa)G&+jFx&2t~#z+ppW}JbiH2 ztSW1Ix_Qq79jjYO@ZdL7t^{~a(bB3@Muj7z+!R7@D!2IL&sE|Cd&-ltXg3{T)hEQH zc!Pn%4VSIfMxFM7Zx-nw+*P8%!%K=W<5YoH8*73rER7HGRrhtR{3XKR1LchVXr8w4 z&F<4Qvv_>Tm`uGwVtUZ=RmzxsTQ--h;?T(!uJ}k_*&-TvV%zdX|5?X;kb^lOGV!Yv zC$M_3FwpLN%NFHkTS)Ips$&n{y_y$u*$cBfM6~qRJDtreh*Pa~B0ixU4&d>9MZ-WE z+@*wMiRam64$pypD-zaH;fMg@%y7`IHLYL(CLZwsiT@r~ZM-$lR*sFSXDZ~Ov#myX zq&RABCJ}(r&NfKBw3%^mgiVracs)8}MdkZUQ#2lpE-!k{y^F~!w6Pt@5me|xsTDfA zoTC%yLQ?NMA2Kf8lXNQeo)~Hq7{OhMdX%n+3pzOB=>KxTwGZqOpBj!@`sE%Dz{f&# zi~*;FCSv?R&He(K5ua4A-~GbczgG%EMklqM(th|P)3|*(D4;PWneR9X+!B=L$cbYY zeK2P{5DkB?De@lTERgv7qsN~D32AsM+_74XUb5J?8&2{ymHJcU36W# zVpj7N*t@I7=~$3Itmy30b;J!6-f=$HmVoEE73C1-*?PkY5KrfUFRr1D#xv!Kkp?@i z8=big#sFKH+C>705btI80vaaz6%{g;EBI4t@Vy!f9{W~7(T#;h`%$7<$Y5fP3ZJ|F?Fuwk_nD5H{+R52Lt`%sTL4E>Y)pacP?`r35yok>flCv z=826WDbdJM0l5A>F4bcx8GT4j8DVtqnXrC_SM-XAxRZdA^`w*1dW zrTQOk(r#u8>v6L4xa`;9UHgJ>56!#10{V(@!Us}{f`PoJqpu&^?mUQ=8tl|tvOt@f z;B)*&OEFxwFPKYQ4UD*bAY&7?)kg~V+oK=nVv|g+W0;?{u@Gisi?-5yKiyyW3TLlBr;EB;e8wkeQ)=-v+;L+t z9Z+E^FnpbGVv8A4KzUhehcSis8ornt$=8}NtvK@H`*k_iF79o;!-%qk) zBY^Z727Pjx(w5-K+q7pO22&2lF`bt}`)e_f$rxkICm7JQ7;!mMr3LTQRHGV%&3Qpg zyfaM);Q36pG8&KI5~gk=45V%ozs}F1b5Ym!i$)-;y#SWI=j&~67gs5S6k~x;(eekL z6(pr4mD>ki7Iv)KElFp)(GSY_TYFOtAK=|<%Hs*d1MjgPQM^QEjR7@JySu|vO4?=O zsFMJzY$yU;bR}=y6<8~C&u2MTnMd-v%E&X|7k~`N+*p;qA?0QR>`xDo!$Bx8@4W2h z_v(%4^7}-xs1!Gs3{$Fcg?isFaEV2cz~uD6VZLA<91(TsXM5#r0K(07q0xIzp7s^i zAE5bY1<7&9TgZKF?5N+$jyi(((15jrWVMGEw!yt*Uqcwd*J7&h&TDmcLiO}-T8E-o zB6E&zBWM^NxwaUnUmXE5pht>)GquqXYfqb%?EB#fN5TETsew~lc5m$_T}XW2_R2s! zb=$RGUi3LT!M!>st@^(zx;fclno7s-i`1>)FUF&(x~`0o)^JrfQNrhiI%vS2YhifR+NbmjKo17#^Sdl~G&!|J+AfT>gko9bI-$I77AM|)Vg ztIzfMr;B@8H04X6c=hr=zb~4x42rKSgmfJyA0p$YZ`qQ`d-xzNFX(SPHhVdHq*9-H zaPuJ#b8km~az|=`Ewe;2`A>XM*tP)|Xq0rJ$87fp4a(3$qebnGa^`9x^RQlCHMiiF zwb`+G6jEQ%(WS);?}j_V{QVrmRnm`h5}opXQf;$+QGV18)V>E3kf`~16Y-> z%WWQ?%CdyAXE&R>f?sEQGdjXPEE64TRM$dFNd52<5jVcTaGF*)?SeNT!3vDN)k5GD zY+Ra%I-WlJM%w3p7_W12pSj%HV|#9Y+%Bb~~?lsJ5tg5@Q9D3}K(~T`8fM@%&wmJ22L!*k1FY zFhaezCNdYIwO+2|ps=We62pe0yp*BUU-ovQiBxM?g6S9Fuk~iLSO~rv9hY6FMGnBJ z8$y#%x)h1~4@Ve?4GJ@nT3k%!f;J%Z7e4E&TNu1#H;H~wu}2+2Uf}xeU3peA zOtg_Cq@8vaNPIfG_G=Hzr5iC?DUdUtd}UCQzqNL|i=g>%G5xnY2HT?3oW(a;07LC^ z!sUH7Z5e=iUkZ`$ioFc#9bjNUF=;k$N3Q$UqAt#hIq%n5MDy~_p9-A+<-6a(=$uMde zPzX%z>)7Mz5M1ZqF3B~_^7sw#+pQ&9ib9`zhz$=u!0v2Y`P81I`KkoSBRTGJy=?UE zsE{3O5c=Q0l&e-kUc2UV$+C3N;yO$hM-Um zE#Psi{YCcp!PGtffMQ#BGJJIJB^HA@^@sp?zY^0KIhM={kgl4&4xg+<4?Ohc&^jZV zo_!2!n>$(R$@mf`!nj|)U&V^G%ri4)ZB?gmolj%b)GQzUaX?DcB(Kx1O%A*HSh1du zp@*NHAezm1A^czx{m2Pcl#4XU@%`pxWcf?eoND=C!26ryj^*QsHdEabKHaJqgSXsTrE~LmI}yL^HxiE`Qi+x9 zE-yXoKUcFv&gCWqAsj+R3BZ0}og~)E-Yt`<$c0n^5o4X4E(Oi5~hp znl7|L(`uDII8A&?VR%Ff@_?*b(Kpofc|T*Lmp2r2pwkbp%?%~ds)F(DmJZBCyBwN; z{u!2T`7Cane;v4Xp-1WCWm^^MrU*4za+=mSg|IZq$!DvVLu-WKkFwyMLg~FjCSX%wSWdLz^_tk_a}^#JTK}}Csic8XWOPM< z+CzL?%kwkdMDfyjTa>2Ia8pG_UfZSdSSLfo+D@ZFa52h@`!PglZj4^aht)$K{DT&M zv1SfzgsG5MFN9e%&j&9T;Js z@4l|!>enyVwIgJTg?3JZV|7HPHP_agUwvzm=2Hg?0bl`oX=3|d%S;-GwW|rKXB0xP#neqbg6Hjmm>OxREI!_#Ef>$SgYIxtE(ZjaI2mYPp;u*Ox1f z*Ni5$laC6~2cs7C?nu^=Nmon3O{`B#pV{d-5QR04r*rriI)0N7e8O$EkOAq>;n7&c zgr`2_O`?W5O59(z*{)p@TtDtLEzhgWYI5xkrHGPZqJZBOv{7VKO9--$o95>tF|-lrCnd&S!{FYqruYFRA8X@-t`d{*d>iv)mj+H6|9BJ z-Xdgrr3i&Pc${C7bSw=zuAOVvvGU~AuFn<=$K*76VqK)RxL$`ZqR^;zB9_o=g8UR2 zl*J#{;zRgzdiVttBCT}gvJ%=TG|jBSog9+G=`U3l%CWY=iiGOt?D#V?jD#0t$tAQU zT0{oSS{h!x`xgm1zis4v;=eAhe=%yfuY*hV6a~2*ZJ0E;Z^GtCCZZ=ANfwrO9?VjX zEsenG98PFf>`uR@h-!UD^WesD#N{S6AFkruuQ#3xrrF|vXNu^xRBXOgkM?-{s(yy$ zK5H5whY`yv4MjgwR=&BeK^r5cjL-N`u>G#`U7>PCl-O=65R-blZ1hc}75W?yto0y7 zCZ0^sglvq6bD!<=&JfeeLV4hl!bF%-GfDGVSrwko`+lItYxhKc!D&!#vHF!9;)jf- z7vq*~BfD;?RY(c1de>+@g$%YFMEfu@B55>AgN7$jwt^H*~wy1TrbMNdHGDa>$UjE^J# zY4JGO27DWph*)CEwNi_mKs&~hy9s>sRi*ok94WQ3+rZ<1T>*T|kA$I-IY@2B}D<)z+-VXu@m3-FF@+eB$O zqThNG4fth;HK}@XIZVgkPY(0%I4#XBgkaDPjN=a-cqDhXpUHU&Fv{8&g(Y2Om&$Xd zvuKjF`(WZChcnxp?q9BwXDO!9ZQyRL2*m0LPxmNbtxP-GJ!h*sEgiYR@HN|ED3nNV z{^=OWX=nSRV4-y{Z+2RDd-W36bX<67)YVncn(d)v1rwZScmp!dPffb|K!%yHl^xeN zz=lC%A(Ri4cwFBxFhlKlY|oE*k;pXFsXq>Kni;zwrzf!Qdl9E;maH^urC2Em=n6od zXT7N4@%bbXu=uJM5NWYSHvG3D&#SYtJq%N2^3>y^XG|nVQHBYM=l66oc{D7Drguu6 zr%pvS#t`jA{?;R5#B1q_*J4cs1#*+YLz@J#9j%&MDCY|~xV)N8;{|Y^=ni8B4HHrc zrW%Ykxb=0dSz}fPg8&Z$pWTqlKVh&mYH(o<{fz&k>4}{QaW%QUMF$;@sIWq~6_fP%2)D?Y$c{G{ z>+ZrK+`d$*U{Hy*`^zjY)&zD;2{@@OTEG&8YUIP0#_@~_tSO@_y@F=zOU|O*6R5+L z@9%P$vMeVKh{T(=UpjmyI`IzD$6oJx_443i9A|t!m8byndB}%b<6vEmTtX2Jlv`&!kuoVdOA-asJR+C)4Mje}gw1G_2(`ZMANNBD3V+v*1f+uNbj(WVlc3!sgz-XHt5g?i*>YH+1~EXjwC* z6J&2TYHZx5{|&ulH01p3iAZ) z&tQ0DEzmh(3#PpUymo%B#YcShs*b)8nf%qiQ;u|rA|hr_(P>grVz}+P~A5t zN9JS`xi#*e-a!)cZp&h%EzUL>|+lAF0wr5{Pp zV1X;_i8}TDvD>96^Yn=G$t1vZi?!;9b7S-#%;x zG%^)bNMA+oYY$6e@JeGDd}` z+NCCs_Tl+npUP%gJEb$Y4&38U-1~BHEk`1yekg3t_x#S`Hs$STLY)i~Ow!%O0!4cOrJh4zU&elH{(%T&yzQ+R+ax9=}dYIb%=nBGUt?De!ieD&?2 z;Q20*&xj={HCsdFL3ZNzg$@1dF}PK>2*k{IZkl2L-Fn^qyW4iLO)&8SYMk7K9~;c+ z0;x#JfiY-6lWOV?*Jc!En4aiK$(PHT>XRhrUo@S-1a^sn?BaldB|e58aqxrRd4re4 zYd7?_T5H1}pl@(N$U*Sp<^<1=>}PY{rIn$e?IPUgmaR)MiS(n`wccUzA)yhKJVn2bfb@*ERog$I;PQ@_St2Hk$8@B ziUS*NyKEaV)x;pxfA1CBr{%5Yp|4qePtTvK|k7<(!5?l$kx9OFbz)1Bi}{YX0s{- z_sAJj;}b?vVGjJdB{P&$zhfN$$J>Gc5GK1#l#`&xuEA)Q+Hc_k8QHlA3g^bP(Qwm4 z62aw2yFTykHWY9u8*NM876pcBz_9?SDt}DR&<+gaeI|oq9OanD`&#)_qC~4%slXSz z05fvD!GBGV7Qz3TAWhgKz7s{*e_&b+e?gTj==UInG#*q}ZmW~H-&De{OxUl}brH=_ z+i#^9xP-+Yo)CE`MzUA&b!#jvcdhC4)$){8w1iDx4`@d1l_5xGY7A68gJmjs-XMmQ z$u$|^qn;K`XgoLWkP}UlU3sMNeDpg1xGs)t)Z^u)8W_P8Wx~t!QcFQ5dT*|muVkrQ zcOJU$8CKRAzTIn<%DK)A}$YBI@Me1h%Lnd>@59Ff%ecVHySB*5pN^l z|25;H-(5hLJg<`RdyBwC-(s$JpNLhCvpK|CCF2Q4iBsu>o2{$)bT*mM-jK7m(fa82 z8C6jcd0vwN1*jeBg!)KWT*D@j|7~HyQr4p9mM+)Vo7Z3&75GJ8#U0Lf#{)W$J}npl zwr{1C(XBv?3rm7DJvhENt&>%wl;~Cx~64$@ma|2V+ ze?HepJ#x5+=TP^9M47Bq07=$JA1llADQ_djirGP=l#^9Q@K?rZ$m-qC=k?61k-82? zt@+tlVHY~je5ai~RCo^!VNfE}<0;{K5vEg|!~}sdtm<9FhSh=m6?%`_EhW@Cz4@op_KN4@187BbIM8Q<6-y3_+G2z;YNu5FYG0$=AkdGY)92@QY+fa@`^9K?G*rE_YeWX89*69o z@3v$TH949VSgqxqVF8!tKH8QOKT>1EV-8NpgY>5@ebpIAzD-xS2J9`Yg_j<$!{}>{N)y9fSPQh zKZ-GUFJo>9b43?pm&5PUy-gqi4;D*Khkev0Yp$>VSwmpC4tzU%vpfvddn55GCE|09 zK13n$?-M9R&-{cfDOzTeyi+yT)sTXCh6$_|vk^?w{&t?J+^WOZ#q^_!FN{Ov9sTI1 z{QDX`Ytk{F#jvUS-5d3)tj;phyIAQGKJpr0mZJ-5riC`@NfXhP2u^4(CZ2Xn82pTH zmOZXssVSL+Cq45rEn)devDbd!{HXAU=k(hxs{cQ`DXBmPO)=todOK zzph_lf39aiqI+C7pB9shHj88V7K+O$CT{i`?h#jiA^%`h!m{Dn79?75UKzbym9(=9MOVKfpOYX6BZYe)-E;nRf_ zkLUihBzM!Ruu;u|QQp^POxNZA->XNuZd2#D+fhr8QSnQdf*sT$U-E>iM zloShlu^D}|CdqKHP^8@ugf%1uJ@m+hL2R^fikCnB1jJo&My)xnMg+>|9!V2I>Wj?T z2xx|4uh8DkQjqlr%K1AbzJp)$ei#Xor}BU`7=YVcaLe&o5t#wEW2gW1)sx}ATZ{%B ziIOS7XPQ=P3N{f13zvXKbIw&64?t&k#E0W^z8$>@qSPD6eOrCiSrIgy9*bo8Q?rzW zYY+nKx?F`3BH#G+hmwEz^&l5$Q5WTm zPci2M-Wt>8y6>SYiVDM1q9Q>PF|q8goOFL?;7U@QgU?XOr^j zQ)_>Vr464xs~xtM5#wGSQp<-117-o$!Y4KDo?vR1kuLcIL!D$MyLL9K^wVUxn*!#B z6zTb2&=5ZR6Ic9U@-Io=wGLA{k~dF*ryRx^vuq*MHU7^7KFyO&cM;81mn;AllWxuC)DTszui zlU?OCm**;ZXos9OmSHepouu;sjtB2EAo_tIy6za=!jvNgV8MBdl5b;q0A*O}t5^$> zg|_Nsu0H)j<1p+>g%r<_bl3xFwmN1y;Q3)#ktQH>sMHS*nd98?S_D19T(46dp1U7{e** zuIg;};z5?`VMl(_f#8y{YSWKy-=8L(#<7(ikgJ7t4N^Y$hWOKuRaj=dv{*1|G=fk!sYriw4gyPebW^UF0){T0-;4b5wWE)m8bSQG_q$abx!x za7~pu4H)}+OHo^U<;l5%raUBr$s#=9&@Dy?Qvchie#-HbsQo{(P{Y2UB;x~;%pOqHGLdMavpw~J%<|JpBAAblUa(`xeIC}UeO7b3r)4x>un$NF< zjJ@BLdMi;34XWB-IL)IQGLIxXHCp`XkraY=DQL9D7GH31MU4ctQKfj>OnPqIua+1i z>S2OZkN*d$@0r~MG&GmoC&Nh*@Y@mv;BDrX{|;|EPH2LUK)?Km_gqiVKIL%XH_#^4R&{Q( zbn9SmUDR+kk&%ZJ-mc!miQi*KX2nv@jblg$H}4%Y>&j?c%Cv# z9ua=j$79583pdN>@tqZTMqvdrD&%@AZlk^bTHKi|^|#t!;K{92H_s+WsWbv6jgS+)80-ua0~G4S`8c=72xx4{93L=mJ0*g6E8v)@9R3A-L&B8&KY{N*w@D`! z{Z;?arK;?sT{iIva|5A2Uf8$;7swAVir;51A_ir`2FE9irD+2J zCVZ#saqu$<(<0AnWJ4sL$>X3<^vQzD%8tKs`=v>7hu(=~Cu6}W*8CBx1d3&5MNoTI z&f=sUNqOo${GTKEL>v930*k=Gy(1XFyJHLjJQ}#pZIeda-Sb>OJ3OLPQc8Z}2xHbO73w4W2Nr9*?IeBcOr-to%eA0&SSPMv5aL z&doW);!@;!Vj&HG0HGS4L4b zk%z+M1Q=sJ@*}M~pks~C9WNHNAqFifc%Giev#mW0yAz;4w{}t^MW5QiA*CdH%)VD9 zoLU0jE&TNhvLRlq>+PF5Rd|)o@KUEt=-)_X-*?$4M7#_97SDDB39MOBA(oFr?Tj10Pf~{ zA+ZV;i|hpXY0?iu;&5-u6^~?q>o$_9P4{DCLs4tm)R5qQm(dCWuKx(%zpf@-zCYq| zn|1}JU`XL{#MH`2rf8IdBO~0fTBC!l!s94oYK<>Db5pZpK6%^Nun%HBbu3(A$3C{c z*aM>UZvPP9LslC}CUIObi0r+Pnyx0{x2gtJnOKX@I*(?ovgAu$ zp7#(Zv~u_d=%sXu22cxv&@uHVUyL!>S^4S@etdbrt4(RMjDG$o;Y%0Vb}dJLE{lP- zo%p^r+qhGKwPQGT#y-V5`6|1Hp2PT1ppstnQ4eQT1Jk#LeHZ?G@|$k3L#Q7sx731< z$c3^T`J>iUD}uPjKPD^=n^=m4^KQLd&NdDWU#K6emKk8g=a2tyPbT?KPi7)mQK|EK zA!dwr%sHk;)od~|ZlYnNW=;oAkX^Nx$g1YQwrZKv36oO^nJq(; zXMPK=jsHOn25dLP&w*d#?wS-i7Z?&xPts}HM<>=Jklx0@RDm16%`Fo$>fP;mc+h zwCJSyKhX1%&oxMNY;_X~Z9>F|xF~lKowP&5nSw7~4p{aESSA1>jAhszqrc+gccI>H znBNh)#^p=-fz0XgY{c`wAal`G{8sq9F$=+BkE=tExB8Lt1{>~lxcsXlxRPvCqw~mA zet+Bcpu^a4J3x@A6-ukJ!##}Pn-+3J((mqg3GrJyYv*3ZR32c^@;U!8@?Fm zKl&2TV&@``^*PSQ2NB5gs1kvJq@kFhDS8%wYn$cF0ryAqCEAAk!;e`%`Dn4;v~tM^8KOTPrxNFM*))gd@!C`Ly>xT*n2qYAH z^5yn0%+UHygQZ%-e&Ah!jQw36MySS<{{aG!UfiMkl)=UJO(fSA{nLGFXNbCEsJhum z^&q~=z!E<8@whR2D9)u>Y}`NQ1n7s~)yTz(`=swn7O0&{Yw=$Nm4duD?Zrn2SfiER zA@s%6GOuGCi9P|gCQbLWE#ps}XyM(6UIPm~YEENcM{x2Q~!LAYx*G}firz7EU5w5gCZXv+WXAR%@o8)DREq}4D z5VxsDK1C-q`Z(q`_B~)haAmRixR5x=Z^xrXes^-e+Ta1F^=QIy)i?B`C&SNj7%V=3!*;dQ z`7@Fgz6^yIZC2$C`4nl4bx13ox}FUq6H3$2Rf+lQo~J+(nnW2`V=IB*MYHVEgO(Ch zniThS_$Hl7z7lwUsO?x?kem$MbN4Mq|+M?l*^u_K13aGf~7u`6!da;9%l zaAa85xl`QJd`b~5bg0QRo%`r_n%t<=Kk1>$KIQ_Pvao*oZYz==)1$NBY(CWosOIH~N+P1|AJn%36?Ey(G zaLait44xB`VsVz`i?`XU#;ksDW$k{)wzMe$n^U=-JTrCfGmV^3V~U;9;<|#ESl89C z94O~$lC8)cRkUm-kD=)SWBIL#ci7D)>MXCT2Du${jS!WQ&YUZT(8#pwb4587WQ!8L zJ#k46wy})_&-Fb}k9L#(RPKSaryF>NBkU^}{;qYB_!4Tdo-ig{Jby8jO1{tmy0EJb zYnnLi4#;eG%CqDBb}RMxYCyM!KS#KP)D&d4)(|>?OIgMuc&$}WKsxYI*m^YzafEKQ z!4Lkn4{Q|amxw}%FKzmA-A8lYNK8JI#gxTo);Nv^v#h_z6?9^u4_YW^D6y8J~J$euuNMPK%A$+TY;u0?A_K2GJIH_H63Punq z0zdmi@*_an`v;+*^jdrf`sw2zpCT}^4?L#9luuNx?jda(8fS4I?r_c)d{+)@n+VwI zg^fQyY=-bL=KLG}CfP!BY39VeAl~P0iwWG9a)xeD6JP+1``e%)g7W_29qHK0C0u=-g0|~-rnuzIsUw9tTh&i22S@A z%;AME1dge^I>o;&%UD+vc+fog2V9iklmaxIf2HErQXg?OuA--&vyuQbt-6t#zwt8=2yz)E zXROfgtLM<>hjDO(9n$?v9E;IFAGtblNPWNN0UDdI*fa;3;?g%VX(&LHIBnjo)LYD6 z*5S!*uGjT15cwnDUlQEu{}TzGX*qgf@PYWF2t=p6XydBQiSOmoDu?-|-5>ygx5||h zyU$YP1)xOjJOa`{ojo5QI^5Bc@4PXN`S?Hhbp!z8b87Pef9HfuncYL%?|&)v|A9;Y z7uxz426P1W$p7HKiEC}mF}=mU{yhAV<6h#e3;%B~0GK>qfM!qO|BOZA9n~1jfA5}4 z==NZ4y*>!}#pxQ%obQ7Dh{J9^h=xP@fBd_j4WJnWHTyHR{QmdP^1spD|K;QAKx{CB ziElEWOL6`G0nYy`%KecFA}WxIP$uv{F#rGkXkO50gHbrJhSYvVnxh?sA^Gd${?RW! z!hYIy)BBmuCs`519*$zkR0uTlO%S8Y4JgPpjL84_BmBRi@T0`P;T=ZxQ}%58W7(Kn z#xvkZ#K9Q!pK$?qYL)Md1qt;eH6<4X7zlHg08j_l_Z!e%v_66J#Ehy6<=EUDxZFM6 z26_8HZl?pSu0N9UgGQ_8r?54`ON$0GY97w3kvKw}BQ6hU%cV*ce07*dh~O>vr3_lH z-jwk|Wt;|=M|AfF=aS>5O>q4d&(NAWyz&l%4OF~;u<#$>18mpwJkJ|x5(9J$M#fd7H2Ns?XJ&nSI23SeBo3!1GWY5-Zhj{-?yfpD>zD)s#jbQ z1BoXeBCK`R9MfDTjs4+Gn0}wz^#MGZnWeGSw&-z*Su3LT?ujOl-GpJi!^!NVwpQVb zEY=O(z_BXc&0sfydhswlBC*wr%`o{_x^81N=3O~!^_BCH?xD~Q9{8Gx z?E!uhW3BWl@KOA2!;PO!qH-!;kB}QSb!N#n3w^g5(pUhyKL8-qEQg>o?uU1QB4&=Ei_#LXn*%4QRJQ>0bxL`H3J0sj3{x({KcW^wA8Nl_=usg0h zv!B_C07UX3EoSwWzk%z^I&gH>eQnf_Yd>)QFHm`ZO9BL4_EyY4P`NKL#l0{qOGC*X z7e*ARFr6sh&P{5woUt?W7w2`M(0b1M?q4PAx>4ccxZ+W%r^H=Rsk{!HZ<=K9>jBAp zoz8>(gTsnuhar6dYXJU_X0lEh|GXyRQ7uUs4swjVK*Si(&z7L173!-cKmxTQuO_u4IG?%~-4hezv7Q)CajJ4rmAy0_L7_?atE9+kj}lR@GM@1%)hK zit<}me(4AGVf*4+i=W`eh+XT|f%>^(81>&#;m#%v;LGF-h^{Q>5#pYkVSCFGP(E*u zJ2!iKQ|5BkY|F$$2Gs2#AxKOIJgHuMSGDB1V26LX*1xmqa$k(sD(QcZ>)0*}qwzXW zJ6zLl)RBnl29|^AX$80l=dneZ*x`h9C#5g5?S{y{;oSFORxV4fVI)gSRLr}mybwf% zZRZcuCHN#Y*M$RWQzT(RTnW$ei6Z7V{{sJYdFZBI4z()GZ?TbwkN@7>-(iB^GDIAU zIt@?$af)PYMP=ospILMeDQ>FtMbimUB z#np2TDNKuV6EbcQSa%I?I$OJqk`GK+ECNQd2`Qh%TK>$6{sb&newR=%aU*9d0Ab!6 z(ECq^dK_?WjGD-rtZ+)mSnVr!!h5{i4hgk}-j{ye)wpr5%}^^z%u;gCfIQckUsCTqoXy+CgmM_*qw+_1#9{o>SqJD;}&GJ(}o(qDd@yxB`1RCd~X5!$2QN zm_%6WX?DP>iypm{>Md$b`2tt~_c1lDIQ7CH4d@03=-pN)I&dz5YGs313RI6AVz?hk#*m!mT>9Q{UR#FruFL2=P#Mi6O^u$YTvbTEbyL^a-4*UULCp z;$e+=UJ=mxqu8M|wlCv+03N`*;@u+8z-f>CJ4R_fVl4HfDS_pUg};`iq}q#bdT(+xLkh$ktI3 zupaSbrrPVlk-uM(;RD1?qk!ZYhVe`;qy3}ig8PQ;S=w-QdmCOD)HnVc$W1BMhp`xq zXB4LT{`}xhkwV*!@i}qIzzN?ql>vGu!5{o{m#F&zTjzQ6>M%M4nfqka2yjJ;)3Sva2NkEufvPvY^0)|g|AT2HS$DbmZ~Tl^=> zf(pYB{*DHbKY#AKlcW4F#s=JXeBPDIUboo`Xr`mUI?g3g={asT)bc&NLG)=-jfyC= zxADikA1x&A`mxNFMmgc;}i@?;L}nkwbc#x)yWl$1GCKF9K2*N?{$_vm)xR+<(M=M)W-C#0nDD09+x>LuNc zG$Xt1CIJ^9EMfIDSePj>D`8B;R4=W~{vq!4e&~WYuQk1%#_#x)2GtWma<*?+4p+c;>vG zsMnh&Z(Wj4)y(aQ{V=7C9VVg4+5FZ=1;n`pIWiH=wHycImKGlA= zm8q?a_E25J?L7dyS$KCQ>(K6QJKjbLOzar^u?LXpdYxUaCPQoL78z~Q!=L*LG=PDe zTq8fE&3pSPZwqVKT@n(dpTqvibAsOtrW{S{`JmCko9{njGSN2!G2IC@7Qvz-fo!s& zYo4h;@}r1Wnx((E)n7?F%;i_%M_4$OnxOQRK2NwT#usg=o?N&DtDg*bjlw(A>0^LL zUs&T-SA+GA@GO;xp!jtetdjmZ62Mm)1xd{&ab9+t7fuOIk__k_x%7h#xQwuJ3${2@ z-?^mI#Gfn-pSX)onsJ`iCL^sAUv6-um(bo=q+ODpaI6B26D@Z8p)`5iNW)38)vVr1 z8x?y`?8m5G?LtS!efC;!>LMdmT(n&dX5ybSYd{C>8}r4NWVSML>~MM0P=PuVLvZ_y9t`QqRN%{t|Ob+)6tM$R~{3Q74^oDLfSJt)`6wqsLGHlQRX>4Ea)4|*6t2g&FFlL~oh<<$isQHr5kKQwV&q)Ub=En z6Tzf*rR^cH!l@vCAc@&295T+_tzp-F2yjtGZ}K1t2cg^dBI4t?m`|5-Td}&R3T1Ni z{lni^lIb#~^(N{3jJE{wk=-YB!^lZloZfM>nPRZ@L(7bnJ0YkM?|4%s0_&3Z5AjCi zqnd+=<`e{9!?Nw=vjLMr8n1oKxpU$l{_w^%95JP`l;sYo@Y!a~JapZ{tFLG#38&B8 zoo|@*58B5+r>p&xCr2^*wuaeujN{hO?|lk4=&`Dw-VfK-z{j#@`(muhOi zo6S`AB;yDaep*@SqRM_9Bx;HDEM`X?hvpJROZ0QHIso95`yz$tT_+yWYZnB$D&rULc#?25dB;rehjt z<39&WpH3BCk!QP&{H^R7A$k<1lcUvKz%E$yax~^ZN2qR zkr`9%2=bX%SlA}NOas(%Vy1I~@XjZIldjpy#GKMu_#cpSoA-Z0&bycb;?*W|m)JJJ z+VC(_m!C{f542A|-&0wGm7?>uiYPEyv|@>=u?G`s2NjYy!q6iu0wZkdV(+7IdiMT= z{aRjO+EkaTs-}*JTUIp=5S}c(H++#*`LoV)tFyM-XA?csHS_Zp9rCz5uM2R@__&Z4 zYHu96S+^`H+bfT3B^Yo-JFSBx7_8^P&!J%pm*d4i7 zp%-%MhD!bczs;EfLwZRjkPPLUD7B8s3vb=p*MeNLBEsXYYuU={GrrZ+z|WuZ@nU7iq6jqM5w%o3JPqlib!SDEJg7pUG1 zM*lwp)p`F3R4>Z1_nZMhb((15{{pIeq+Wq)#Wpf>zK?$Y0wN7^Gv4l`GWFM{-iR0> zPiD@4hr*LV8yOs?xDM+j#&uZwldpq?|0!#l4L1kIsQUx@bI!xhC`+dI-$wIz6|RQ3 zG6a8}a7S3Pqb{x9HnPL#M;dDvqD>>0+0{st9jdwm!Dp%KxSW3b&{uP5J@r3>kJtS^ z7(w#yH@EgpS*ylO`3IWmx<#`_#>Jus72uvSlf}o5IB$W1(6mG?j_OsQFTXKhq26*! zY0}FSBQ6$5H@rc)>1d;^Zg_sTwt+yGsGe2v^^$Fx{Yi8j#^D&d4pQ~L@?EBqw%LrM zR73oK^y8*8zQRp|uBfGDEvIPKzM!7@f1_?cKnn$9RfNK>V1=o^`@Jl@aFzA_`mk$v zZ_8#9sI$^Q@Y9Ln^I2N-_@U1s+W0-%cv|!r(V%Xl>tqA&&l;coWZGQG!!+;2`gkX@ zWXZ~UbfiGq1>k^pO*qny$# zUfgv?xr!Ctt~P1^cpI^PCj zP!UJ{+IP$;#%1Zoj`mkRoei343l{<`47>VeI|5`m8FW0P`hTwVi8wypT+zFXcwGtx zgazVhDVcxL|KGXoq0#>;_S)}#(17@k}aRA1bcvvEa9GqYt=E zxv9d!VevP-jNR`sP2Dm^>rXrh(-HQu`jdS5YLhI?7X9!*)Xgtl96HxpOC#D#SEH0ubqKF`jV1e;N(oLZGX)M{xY;C3O+|}%K z)orcCZ1ohHo}SKixt7jt_H8^P&`P^V^Y*5V5vFM*L3sCNpxT-2T}(*3FU-Yl1q?y^ zk#T9d7l*gzC>&@`&UIU?&WKjC+ z{gFJ9$`S+~Y^ds?gKhppWQhf=8v7AJJ$q6|&9m&m)69~<6Vr9%GWnjbs(oL#f#!5? zrc9>~(U!*vJhoISRvr+1!Rk|~`7$aIgK*ox6YV9_;FUN`&H-}S0eU!{my~!dTt%@13+n(DgFdnx(%PvmW8b1+>URd@lJRNFQ9Ko_G z;B3l2Yllo7#Hrlj4OktDH*v6|>DMY!JjBJrw9zft+qe9jRjF=x-IVbb#W zsF!-wh0UesP?HIxXSd_C_ysK?^PNso*Danen!sU)Xj00iZze-|;($s|RWD*;?I&Vc z*HW}{7`M%xMljQMbn+g_S6F<0Hy8>-Z+dO+pJH6unhLbBEs)7PWH{3cO!RVzqB!nU ztS;IbxNgDzo1Z)=xsN?&9bQPd?Q2Ws-4-S%!S5>fCy*ChyelAq=d zx$aMO!-{_9R_-Wz2r7iyr7BCxtqQN$x9J_$R129vg(zpyd%2Z2-b&pR?35;JceA8Va;1e7Dgl^Lm!=RuyuDUbWvy3sC7GaZQ|0G_oLI9 zcWXs$$Iibk4d6<#suqxH^!F=%`brzMyc%y1{P}PfPs?RTY$p=VTcT&|QX=RVC+>t+ z)WYXNYA`2H-48I1Aeb}IBUh}eeT7zbX*<&D);2-hh(i1B;ceM&9C?Ky(I1>-y`Ox# zkG^{$SYDLQk2o1N55LU)={kDS&!}>>ET)Fia4hbZ5TPvNZdN;C@7U7?EgvBJRlz8s z)>|Sz#LZYAu}TfI5+lhBfC&ml+nWhT`z%5g5-7r%ed&>Zm&8kOp?lx422J*u*SWK< zNqml2(C2GOeXpsgv4+)fY#Ee9zd3n79W^*Gxg6_qMX{g^aylOC(%3m<2DPEL8;1P- z#cKj@<)qax4$X@!Empb1oX=}HCly7!qI)8|#lA|u2y^EU%ej@5Bvcz#SqWRQVldv; zH7l11t3n-Q4~Cji#$Dn;RZ1?Rz>h>@eCqbo$tBKn-)t;bjgNG0QU(5T{j)ladF9OH zF>3wmZg<4qb-9p%c2m_%V4_o~kf4s$Hi{cqj_K+doOOb+goV{Mz=3(ytAXcGh1Uvj zZ(E>7k9t;GEQcCr_7Nft=4G*LjxP#_w?K&qJ~V#NKAUpy_-%S+&x(TF!Y7;9&~i#L z+~iX`O$7Wg0e9@f16q5_%9fAcvr&uxnMwz>Vnd^u{7YY}(b*x$hY_c<(j=J?hoya= z?s9)$Wv_J7C$_;2E7ZXinM4K8Af zn*f>^J5!(51C6;Kxms8!w_McRjI>;H*|04?9KFgTTb1&Wz0)OBkW#W;j!hTBtM1h$! z@MJd@q4noS&X=wD$*xe9B|5hCBvZ!r?{?26wV`4#g5XM&MeDM z(P|vUr#{|rFxP=kej&6yUS((*Hz{KLn`#)me)p~BB=F6SYOvJq^}^y~1wgkLw|6;W zPH(;HU7_~Qa0WLozVLRHeGq}9TS!T9%=OlA0_?YI0(fo~PYf6@g%dj})}a7p|LYFC z3@^}a#zQ8Bi=|PW$c}9KQtqD2ZcEfDx_Zmp@uFelflM?*T&B$`9$drCuP(wUqzGy& z^iqX`TGHiurmt}&)Wc)QFlEDuopT_x6i7>Pleb| z=A(9_?eZk@<ABZ-40`rSfzO{OzG%vu4Mo!`Md` zM9}uUJV)8TS_tyP?5M_6Di0AtcoQX?bDf|kD*6d+ZAn7*O;hm`_M|Gua#juUUd^9d zxZQ}hmKjoGcIsOM;Cl0j<@DEB+rCIb)9Z+maSm@IvX!CRvC;pKepm}GG~(C(T*!0j zj||6i3^8ov-btG(n6h9e9CRq2>hWrzHa=BvE9~6hv64?`k**We##%#SVY++N&fc720qQns-8cIcC_4k00LjbI?C`{!vc3ur zk2fZuOax4AKCGO?r#6^X1EwkPOVx|#iWub=siPQ@YWV7-KSa{pTm^&@=VBbh2cLnmbn(eyaq0Ee?!dJ9{UEktnTkA8C!ScJ7ndo+1}DZN_S z6p4H53FTD^K!#%vch~)?e6J_Dx%bDi(Q&j}ib1c5Y^mv|2xHLj>Xoy_RH)Mf_4jfJ z#SIR^s8TY6s^ajEA+zh0p`+5os#HeFW;v#s(|N@rLoFrefv#~wK5cHcknZ#TtS{QR z!(j4|M(3>~2cv>#HX4#|z1S8k_-b+*)AREtb+R85Bobdvgfy&b5ky?Dx_0+s=9jWT zyq3@En>@#R2~{%DU~kF^8Kj^H3s%iRLKlyt2=2n37ViF3i)GI1P%5E8*12I5c3xVmLQWQa~FLx-uGrHTO8)%r_7&Y(~I|FktVt z1t(rerZAF8Bpd|z(+N%|9baugWU?=O#q-gWRtDRA=aJI|tn)BOed0g4;V#-$rx02n zAN^olSQi96#w!m^Q^a}~tq^j`?`L*ttG0Vzh8UfZwwtSGy6AbR_6~=wlKbzn*gxXW0S`YmIa}=wmzSvqr4j0`QQ5%)XdFL$E!Z(>e+hy-g`>A zTbIv5Eqd8A{B8OVPT+tMs=4=G!Q!IZq)qpzqw8Esvm?o52f>;J^20tlj`NzVgj=)U zrSpPdS^~IcgfjNMk4bX$+I)zwNU1?&&!cC_4BhYOz79=h1W$Hv9G3jy_v0Lw?t;uo zf~DTxsMMba&QWHN-#$vb)sS9i0 zm9oj8Z^IGCHr&qm2Feh8u+19Q|E!!l%h~{|9gHpPTVx?neB4m((vU4D*`5@V%{v1% z_c?}X{&V!5p+(<(QfHK~-rd4EckA2053J9W{5RZebZR0 zi+>>6-v^QRe1xw@2x?C6-}quTrz?@1+o)wt9%Dsv4+AkjAqf`IYg|T<2nX@m{;7*Py#@7+5NtBp#6b-hXqs!V(q46~7=+$a&y;CLbvZTpLUT^aN$sYK%IeziEjFZQ*#|rqr#v@6&%kV zPd4955x<-GUdlf?;Fw!cV{p_icfBlI=NZ}FEx*8CWlRL-O|?0H!zt&4rI^N@!!jdD zq_6X~QZ7t{W~|rjlltaGQ^Aw^EsROBePocL=CCiY?1`5n3XB`K((_K@PjpS)Y+SdS zcXLN0?gF~;SqfzBK#0ptV25H}qJ>_e1_A^Lu0Gw$s$*cm8#{7cJh0u&75Zlk8(NJD zd6;TfvPf46%JBW1L&5;*@garRGk{IB1%hh?d2sAM|I_!y5l52{2a9EC(XC2Rucu}K zD3}a|&QPZmo^<+9vadvY@n=5sfaDQ%(o6*msI+!q4U-w`P1*%i^!@Ks{esoibcE_s zX}ZquR4VUI*Z#aep#}N&Y$9BoC2yU#>l2LH4tZPhf;Ovq@Y26xM!B@9{kzfy9;s)J zMGn8wo&_ON-^7qWbILjpObLegeCs+)1N8z%8l-*kW&D{kFK|AT!<2Dla+Yj76FHX}Gs78vZkRj20 z&5#gpRa_rv+En~vrB4;jP_cq&aUQh1P)^hZ1*e8l6jVIEd64r(D5tgco~=j8(J?}7 zn!p4Gfa_gs4@t)m&`;Bg4Z}kZ+}NrKxs8u|ofAJKN3iU++PYE`uN>n<{vvsOzB99z z_{da90zw$Ykex&cd1z2;aA5j1M3$1;8b=G_z>>lA%=@jAg#gPI`+J1Fm z4^zISElAH$^(MyYbHGzE%X<|a=l-n&F&bH zACHf0jCZzeO)?v#L5KEbuM&BZ;~;igIq25E?E#kahz!6ZO83E+v(=GPR&kW zXx$=V1$M~F-uV|nUwk)=vd3Y?sorSH*c-UPsJe=5&7b>hfHjA5C~z zciZHgTo@-}4YA+=DuUQv_(I_n>pV}~q%u3zBtpzW+lqw+E)CMxSc&I=lW@HQ=!s$BJ5{}1c?2+!31HsQaz4>AK zoTZ%?`vuegJk@r$Od#l4DgCPQC3GgbtaS?8_(`Q3JK;QA0& z|90dYg45tT4o7NG$t=|x3_7}aJ!(k7GZ=?(Du3K>b0o7F&`~M~#}MRq zI7yi7thIP*=VdA17mE{VZ~vpFYOUE^P99hIe9u3T65WMIpZQ6$T0{Wbbksg1?+;S; z1Jgx=U5}~b>4Th;6;bFhPrGCSbpokid9OFKuarX)o?Q0W$uoj@$9;-QyJ7iw*`-vZ zP07{a5%=3-FQ|Xn+1-}LpSat1H3t&l3*J~s*gEVnwtoZT2a*-9kDJp-k#i$=$Nm5> zjm;#-PTGi?>)f|PFn@K__0s1U)C}lkwTUtu<)uHthqPK_c%t+w0_vq1-$riGtm_Qd zDHMimzD?&^Ks)lVl-*^B3rbYi>{fEqqLItcxBp{yIm zJ~B*FT<%|KX@FV_9a+1>F+Frk`0p4Ry^m?~I&TM9J)=IImOAe_X!nJ-cC%y(JkVX9 z^CCPK_G=ARnT>IgVD z2SYihUSbV?wX9Qan#wGCllNYG==cVcLV8S)i5Lj!6%xo*biutUk(m=i4w??LJNHcL#%cl+{~0meLietZZ?ncG!WDcvioQNyJl^+#_?q zkt$tz-kmIc9*rculbDmwvnStoQ*M7W`Sf+(*=FLXb+A~)M4Pe_eAq*}jD+WxaoIw= znvOaZInGi0QM5I+hvq!eecBy{y^CuD7Zy`uh&VdT8L*pri)LFBmIX#)`E)>L&D*Mi zL5YN>cTnrU0^!m?mxuT+UXy-#r}+88>{4?kX7}RmvLOLlu} zgc8|;>xtgKCina3e}C;?26!rU6jk08QBS;qa>Z|0N%w>$7-9>sc2^2qbno=quRo|M zW%xQTF(Vr1d3A%%bZau~C4(rsN^Rae;qoa?0H=Amta_Iz+w~$+9YfqV~g)&^V4AfR22W+=(O{S-};gj^sqs-m+D>=;EF9 z$@oAMO_x!VB?m|EJJdS)d)%T2*-mx2`9N(|c^amdv{%7yMXHh+%l0-=n%Ll!r(WZ? zR*94;Fm#+X20xS67;7LAaCG_=q;_!1`QR(Wz8-!0N0kIC9acS2>MPlu2npGpZbqXc z5B@FSI4yqvvb9d+SGXgea~BY)zc>v}(p z)g}k-{mf73V{W26-AMu?nYv$Nxum7FPQHfOxc|W_iy=a*(<#!|bn!zD?b|EsCnf>T zjOd9ixY)ezP(GSN`$NcUA^e?QY5LXjD0AV|_RdoQ=_#aEeEF9Xi^if>>Zs_d1mXrp zaGeJMp(qMU1Gwp(ge&fmS({idEt8))=ZF zrA!=Ad*A1F+e*vtL`~Qn z-IsFxuk~BmD%wjW{!D?psEI&GK(GhR7ULTNyLOE|MHA`MNot&W^8^K_*tpmkiL&;J zB-6dN@Klye>;8RUKg<2h*w9zsM9?r6k8bw?0u>8$x@@5H_fq;S7K~7Wu8%Xo#`99; zM(SPbOvg8U#Y`2In2MD%1vcvfdbZm_xKcNk9O{wwSNq+XS|i3@BD+SP1AB)!%&F^ z-mx9hdJi1YG@nW^?G(gf%@;P5nA-Zy2Vyt~KJ*^8|t%>Ah+M~BP>3bw~eyBlvxmItKf#T zBBz8d6Wst#*)KJ_%PPzM=7QVZvv`+q@Q~*i&~E6!4Zj>xurR5SvaX{@vC-(>4u!vF zld;F#`~tIAN=m&|my||UBn$?#jO#^;qdBvNYPtd2LWQ+t;vNlsu6YkRtdufKzc^*c zj);;uJ(--Wp7PjMrOaK*Z@;wqj3fD?EUPpKz+5S;&vKu3#sray>4s#o*kMcr0oH$Z zd2snbJSCBo?*vDGapbC9&~3{i-g_=BX-yQ%unH4tO(Yr}t`jJ3Lw7D_fT2y?+F0cd z4Y4<)#TF8LX|Ymg?|84`@_Rync_Zz;jB|kJ9LdgS?s>>{+O_YDhy~d+kRYL+lz4_{ z!dLjYWT&Y07^Jf<^cUBs0Lm3)TNJR22vT$jxztvUI97b!C>4FKs^+cS1n6G|^WSLk zB>0b6=oWQ`@)gf*N&uzosn1Bwn!Nipsm7rE;n90}$yFtb@OtK5lk(~s4qlIYn5)!t zIA44o40F4U@Q_s%Bk8He`1{|;aG<3s*qE_}C}5EZuHY9|69A=aZm$Msh2wGs7ocIw zH0>l>8`=UMzZP{_{_HrmCw4PHI{Rlz?Ndo~W9Z(f<3t&uO82&A_!gV6_B zCldN4M91V7#hs!f{E#<51X$-)!<4xWp+Ezcsdk6p?2*!)`Ct-{iA9nF$!XAZPPJk4 zo@TZJ>lJN()7#%1n5J98B!+f4k5T%Z76n4?8Zm;iFH~pUQo8oGjD9t{Mj4d}&q>Zr<|P{?e-rR+d6C>>zb9XCJBS(2$8#Sy&3aU@%`eXi*F-!| z8Vld#_Wnkt+jw*zaCg+}?f?>upBo9#_=fW7TS}x@#DvCQwKWSk<;e=Ev91HtO!wUi z!jM7DXz#f_lF0x;f&0C!BtPE}=&ISRhx6YAEX>2M&n5<x4A;6Hwkfs5W6?8ssA}Uc+2_Jv0+#@qBII{Ep(*atv`%&|}4Wo$MGlx7i3i zs?&z-#5$zAIu8xpI7hpLs|9-YT7_+7{^jWG&oRj2=@7MEjyN&WZT=Gwgq`b%^eLU$ ztbA^0VJ>}h)JBURdlQ>A^H!Tcsx)mZjq1>3l`6K<&17}2b9m@(640Ywx6a*icN2d_ zg_NOwmpkgqKE*xlj&Dhv%A=SUNmw88CAXbviItzO5=b<_jCsF4kz{iF7Z3H z0*QmvwZG`ZTgYUw^S3Q;v+>x;LV@o>e&i4rf^C!v5UL(PTW0B^Ugp7TVvA}6x{*9S zf-39C53P#86F-ji@Q*7J$NgIa-{a?fJ>6yZg@LVHZ(n!HX)UWIFuIPq4YEu4JgR+^ zHpiRD8Zyl_N`tpP6z%4%B~NQU74K23)GJZIA*KSp1{DHCPwfJ4BDWseGdvA7c5+!# zTM7SCF~A-pwV!u0?frNRPHH_J+^&-vlBymxPq-(uY3|qDiu_hu+G;nWH=nv(?U|wM zp4-$>^u z!>bIg!J^*oJig;CM-_Q;msKam5)9EU%p z8+F#NjcCOkr9CKe_&L;OCBcDuGT)oO0ij9S{tC}&`Lm+sm_58OZI20U(QZp}U5Y)P ztol+9eplvwB)qLq?HZ%`ZwmR<`E83p`?BOj6G*4g@flauy~h6>tWx=upf6y6u)r~K zIl_kHYFFK$Sa>rVSyeVi6xukJ1`$nKK4!9;X`)3ibv{8ElV!(-b>_~RT9 z6sp3_N@Faz74mjbEg5U(pUIk**zI!-EfJ%7Quxit5nE{M8v@mhq* zSJZEZGXdNBeMjk66{7N^PPjLp{__NOs>JU2p`d#q59vI3mZmbI$BJT)uS>jg68~zw zoDb3{CrKKrUCsHDfwzLx6m~e$6A*euSZqsHKroqwuy3n#z1SVjgj(t5J zq(%|{T76@EW&2KSjX+XcBBzi++qC`1CnH9z;Z|l;W!utSn7YkxDX(`f8pLa)0huOF z8)kx;jQFPwV}Z#KAz@4hAH44ylt|hv#@Zy8D*GN|G=s>stwozE5n1~T;lnRpy2(UK z>@vh@uc%sNb~h8k{~2nj{A7ipVQxqIa*6n!kj4eUetLlQ0%4{dyIew zRWOP%%RYp2Fb26-B>jL%cItYV_X`FDs80XskSwkXe&yhu^Z2w(#{z!!Pk*j0N>@Ej zI7wqJK2_G7l1%cL$U61q7>&*1I$VvCMdB zLP&G)AfjNc!`;Uw2+CZy)Wvj?8H&H%q@#X18hiFX?S|uFiLRSb#|RSIVN!Ns<*C8- zc<%ujfU5LTsjyv>IT^gK+yjj~0li)>PPFyHsO`fu;}kT@h;3#!4e-v{AgoRfYIn>un1$8)KBt_+YzW^|8ke=!hfNpj) zNghX(NelV(li{uAPI&oatm+JtoimEZPNaX8DUyr7!@BCf6fZttJUeQ7Hak?rbWn=BGI`2V1O{TGD4-xsyqHd{gKnaU3(B}kDP z{=yv#_^CE_v7&Kzy>I;fPqYoT(6##Df8T%p)jV47o2-s}r|Msiz`kTO5TFpr&KsS% z^_1{G31`${mdx~)ah6QLY@G35(q|Cw%}(Up3DITWZ>}m4JwshC@c+*A`eQ`@)8tuZ z(2T6x0mu;iwFIs?;sTBy{r>;;UMcGLdH$-3?LyQL?L3mL(G>VGeJ-Rz#*83(_=fKC zcz9(Q&L*P&C(74I>^tT`*Lw<$-v7YA|KB*^|GUouf=(*kTiR^s|FECd(>m+Me&_?UA4toh8CpJTq+qy(aF3=_pb%t4YAS)WqNWwAeX` z`V-mlPa~oUa~&EKF7Ee!lY_PiKYF8$(Bcw zsNuIp6Y<7$`H=d@(vk12CF9Ds&hXjjy^vJtl@Tt!4U%VZM-0`F`r#3w0)27l$O46E zdM)OdjTH~+_BR{cue;neTL0@v0R+@9Dr-%wrXjjoj$P~tqR467Xz|sWXhP%iEHKn? zZ#cJ631Yg4Tf`1=`86I!Vi+X_8r|qY93M1^>wt zblsm{bo#^cPlT|uv7sCO!lGBT4(Bc~!ofcw`k!I%Nxx{)-Uia3A0WS@slL`vUzYmn zpK%=hAHD1w9qy1h5d4EK6X_;XDLgcPF zQOG+_cX+u6A2|Fbdu@H0DdY3jUPGZ_-+(Gm$A6~pT5zAZ7 zIDSAaU%NxW{n&P;1Zw>2j5qU-h>U7`r-8eM9@B6^FZn+x=>qj!lC{j&QSlZ~v(n zeo^(tGHW0AlbinaUai2V3ko%nDCf`X)5w7zOq&6dtj^VV_&>jkV2P!x!*vFR0(57R zMQy2-2p-rE-*r6Iph+-hV&v_dkx9b9>dBN%C6ektPq z4NYfb9Fz*!?L3e=5RTT0=lorP`_5qSv%sv-yB+P!_D~~BdeJk6QPy^|eriGY>DJNR z=LS`?#V=Tqk$h``H|*TH2>`C~`>(!zRcA z#_fP9?fPhwsL}X-w?s2;cC^lGMYKV~945$di<9BFvrcPVdVJHnV@hgTix)4*M$GwP z(`CdhAD0 zX5Ho`8P4^+;{e*{7L%T95cN|pGxp5G5hJrLn&9ereC%eIgIEYKJwF=gYJYUiOz*ty zK!n* z;p0S4@tHtI?jhm?#15HV`jbmt_!3g5hloCM=nD>}Vb96)qN_8foc1NZbNMvZ`&+F6 zDUd!-g}Oyge|Z|>RVE&@-sRF)s@RtIKuSK^Ncb@}fE!7q{)5(AYBm8lMtq}Cx|T+* zVzv*-1$+9D$NwRxuVnJVo zd7#>yL%*73A}^X8l@PIOr-dkqS`%o_PD34_B2Xw>1KyQyur1uqN#-c@*}RxTo!Ca2 zygI@dR(ZQLYra2k6gPZQ>P}nx`@R`LJMo$Sgt3D!7W9*1g#_wm%Xsl#Z2Gd#%DL_F zRMj=Kp8X;IE_MiAzWLrimf6O0PyO^wBg*_N`-X28}ah`u&g@o7wIu#9~x(gOWF7MSvL4AWdmh)hb z&YG){fX91r9=@6eHc?1?rEud9AY&TA7pRS9 zox|T!TF%OKm|Bs$;je}oDBofyw)(E#q}GWr>?}E&P+Ds>s?^$(z5KEXYSz#ZpdTt< zZ1}`kqtPg`uhPH|!&tj#Q(~LWGV^=BLm_a@?Xmf)^?dE)LNpf2Yz(2nT$HDIWwzYy z*44aG_si&+T+Qmnwk@FFWE)(rMwONGKOPG`p>=#;s{QnLBl}i74MSc1NYf^-d6`K$r-Bs z=Bc7_i0}VjZcl#a8Z5$TI)`-!y%d9k;8Tf$8XhTZ9njM=UJQz`p>0#<9 z7k+|dn_?4)iP0CXZKqE29cLgTl+u0+N*9C$+8l#km8N{EKtgfudOikiqSkwXY(>d1 zms~n0Q$-cw%4wu&>#g9NdvlOQleX;iF-B}lG^miwCx$F)u<$n$tEL3cg_-G=+A{i_vWJ^1)+Xm5R*_hF9 z63Hhvm}_lZ^m*%w|AGzDFifccMi%u2TeBKIJ-WG5X zIj_;$DV1Y2+l^!pQ)Hh;o7*`fvU`cVIvc zmn_)rim5QLiUO)=DXiwviw#?F-8%b^*AT(IrzxP%T7#<2+%@KpjvlyI?mG8yivRQ@ z1+5GE88tGmw4Q#3sMR7SjHqA`+5oEAd@Blexxq(mg|FB<*`|d;*)+}CiAG&StbtKB zCC{-g+}yU^W`5Th*NPRu$dQA3Nh3Cz8FG#x1p_|X6xFgP?rdURQx#ZlnTsDzNw7Lg z@$>0~&=gU}?7E&>iz$Q)77u!yZ{rD=WPaI58WW>&L)crbqB1G|8OzodtU?mbAzqsu zhi`VdDK#4hv5zKZN38NMNAlf`OfLq2MDaYISG{w_8eK{3D!f}8%vRkUb|Uf}$}0Tx8yGFe&e7hI+^+)&cSw4ez-(nbeUalq zFI&li80Q@cw=wo`HFTLje#cO{ha`SIIEQqxA#gIR#==a$kC+E}{rj)K*?)M*ha!Vce z``9}&6xXJ+=?r&nS#oE&^>^mE5292`F8x(Ll-7&-B-v;YIH^jQS*vg(x|&}m*xwxX zae*<;aS48pEhIMXnJ{bVsZ#K~5!lt(fY zPfrV_nDSkDKyrS{q3kiZ9LTV`v60ZI68vd(5ItR2qpbmH1o_Q8l$KAr+~7BU_{SI( ziUTng<}9zd&09{y+s?Dzy79Hkh|nT-DP|Mi7NVZl?P~kV-NCb8ag;RM_Hi*$%|mjk zg!We3CS)(prfP)pH0~PURI2DNixgyP2Z2Tapq%=cD;nJ-S9&3l?qizHy&BOl>$DSD zS|c=$cqy?H>FR<-QddyNlk{#l3C+W}_JHnf3w%}LNv`7jTlzmw7e@^+fdVQ9!6-_f z>CkDI287C|n|)`orBsxICGsu1?e@OLiTtO4b%CaB(TGjKfAN`SMla-vQT0ZtF&De{ zbY+<+!)g_tCGVO_e<0wL5f{PkFB9KhxM1@5SQVA_Qp$=k#*FB@Dm!(*@WdtkT9p-7 zePz&{$-c&G4kvP6BAx6hgg0^s#@=YgnQ1OUYh8Z8bavAo-+j>)c?XV8^nOz@cCLvM zFDB?GY2dz_@p7Sx?h|Al7W#3jj&MNSSu0s39_@DCzL;z1|9yicXU5UYt>h*N3UrRf^+rj zqUN;)NI?km+2InM-xHr_hTm-Rn8K)IkW{(UwM3M}zuaMItJ#KaLOA*EdX1TUj`6>6 zb~h;%6=2cy(e+pCcM~+vo|>z2`_8hFX3WIeoq13MH6y;0L7FbOVt*6z`+XyS$dS7n z*d1^~6mX&w^3_E~Isw2NroiRRr0 z9LZK12L+h+ce^(UFydV68+JcRVuXKE2hmm` zz9csJj*0Mf+atWtb6nkz3tTW$-*`{56e4tF@w$s-#ec@L^5sbhw3Y~eh|wX z3bbljW@m!GK5G@(-(rU(q8T=Zg9USb`{W77gn!aoj7yLdIKCou7Na#PQqE_gBD1PU zMS1?1-rwH=SJhrD>$>hw@@?2_c&W(- z+Lq7yGdlhxX5W6EKOQ@m5RUu_b+B|3nCJyG1s-FNnKgQktHpF9y?{6gKgc68;~&4$ z(n;wz42oIUe!#wlrG8*ORuIjx1&^10P|qCJ%Dl@jIYhXw&wF^)zZVhtb1VZk>5bus z96VS&(Jv-*TdlWbrlg#=UecY0{k$b#D1HApp+YUz`1X*w9M^08EkaUH{i&lPFm^L@ zuE%{@;c(}jNvjRiZ#j;Jc!3;oHTj1l&`0By{G+f_n@)SJYVXX`ir+PZmf9j50LbFMlpl!5+HLlvC zxWsxXY!XkGQKH@nb$eYPdkxT+~vwMbU*2Z)KdKb=s%(m_G3S;{R2PuV(zPaWRZh}td}b@Sk{yq-Oc_vm>n1bQXvjX6MT@~+7_ z2#P=UUB($;Hf*V;W6^M_m6fyPF{)?g%U%LE`0xd1+is)vwF!CLfS&q?*-5JnBq78P z1bsKb+kfF)tt=VxR3zrkCxNo#2UQvTz%Vj}T&sSXcf&)n0+RbVS4Z1UmHWTf&d6wf zv560>H>jDv?KrxF4(KpR3Xl?(zt=+Ws`Z6eB53=5*KO6rr1-4B zRfc#D6jKDjnES_M6_ft5`_YFR>L`LPUWWy=DW}m$u zLr<1(CG*?CpTMG*P`S|fLT9dc@QvtQUaNUodFj{V$f2cwBem}G^Tl7=YeJ@k{g+GR zw?%T29xQ)!$-2dB)hRRSlO-g9pXypmWYN;_=2WdlcssWyH*pGa6TS^rkw?h=05s!GXb|4KR7DgEA4;*`-R zVSn$wFoxTQ;yhM%zS+hnWv)}(%=+-`9gzq-h=jjCo^BGR?EU-&O;=pqB^qTWIMZAlB7?~2fnGe+W;v z#nC*>lwGN?YdVqJhmGWt+X$lEN=OLIzSN$stpg4 z>#4lV=xMHEp=(@1?W<|{ zu1PT_??Ez`oz8$=UPk`C`eVFe7kZ7S-^lh&_B)!-G}N{hrYZOC8?C$KoYX}t`c>v2o}U=?xa^?cX)@;sg2^Ow-MMS8uP+&J?8DDA9+;#{`1pAZN^ z5`sGfhrxmcC%9V(?iwt?26sYm*9q>y-3JTq?(Xgu9KM(Az3(~a);;&0`Zj+|)f6*S z^`h6?-K(G9vsj_Z@SARI!MFSP){+yL8mTQMcUBvTv|Ifmb6_8>&^C^i5AJ}v*t^f%PK`6@e3u7j>spJGA=H?L&=3m6Tk`ZER=NbDg)< zrWeypgWDTm@EzBtA?ndb#kItteS@j@hV*oP%!)$$7I5ftzF2Ph64)Sckx0pOJ|$)KVU65$r`4%oG4 zx!9J!{}jJ>gjV^v;8=904iGJr0XS){J6LzYM6~T8(FqVFKV-LPOIDnS)^Q<4sE^JF zO}=-KTnib_D_;t8|NH~iO{htCSK-pVRj#F>-C;`sJa;$>CrELEVg0#3mrWfnvZzKq zWD6NMcAy<>*{7|DOctlS-XA*uv;g_)Wzio=nE#CyI}Zr5tP(7^w~5z;qb^5h-i%u}Fbf~iD5 z(})bHnx1nhgyRokgf-dY?x1{md6-tG-6IsJ99@NRIQoF)DysJFCVwujETCpICGt{< z%%@2Pkyv@B@^nwL#(i8ehR?~iCsFtUeoevY;D?AW8ghd?DSJ8LkljCEacYf-AUs64 zvQ6(xDWY~2>w~X=UtWin2;ic`22jMLL|dJ*z++&4j<|A9Z_c0FquszKPjVS?C(HA= z(uEywn`JnCkjjK4obKYA!WtXmH_j8Q{TrY4XU-%KpGP}|_YKd<559@v_Dh15t%A-7 z^nEPiHtX2H?fQ`e#B4Mc?G35Rb$zPBu*0wdyCXZG?dCt1 zCK#W&NmMxurL5p07|B?Vq?GH9#H$H^ZOjvTYyC3423 zxH1=bYIfMo*cyC(dUvuzsTu<)w(C2?QLbf(pKymigy$c1n}2F;dvCn+jyTqa%T*&= z&jwS@D;g#x@ix7Ns*|xps42G?W}cR|x##p#77_bNjRd~hVAc_B4^iE*_e;b~$9ryQ z&pyjKrt~RwTp-dVdZ^3J#N}{YF?XsRAFi{sqwE>v`aSykC2Es%bgYEh=>{5`;k!<; ze1lnQAyqE`3r7RH+E=7r3rAV-uU7F-)Qhqo5>3N3o)jeow~+^`Hp_^Gs$j0qjET!f zX17zQ;b!p%CgH!bz_z}Y$m3i9P`x-5+qXleoljnq{R<+FlWpe0{FvDq8NDuYVrAj1 zWuv+gwpTMsIE^OJ&bVuS;T1$ww6eR_hbC)f$B$iINp-Am=n+akK#yg2G`6JgL~~A z1JvIEO4k{Oq?&|XXBI%C((ez;_qg~o9YyK!vc)@P0A+viLb*b+UBDRsq{C!H?MN)? z6{OU=kYsfx7k=vtbI@)!=T%<3GuAM4{I@?D!p_#eCk<6J48QJ81#i>yaG zA1=}sxcQXjWhpuN3qC9dN#l+&9@xFjQExMd3E6S+SoZgvXfC>U(0aFdiE~&f>4>ML z{DEUtX^G2Y+5nF6Hy+-=Z|>a_DFevVZ5$5~( zl_(^5e-qznzlrZs=|6~X4eF8CA6l(fO%c^;A_YJh_YSS6pNCmQIkF{^@U(#D$r?L~ zW#X!#MyFWlMMk;I3iq=PzjAaMxbBYgg;~5h&g~Upp|vJTIc5TB7Q-x6&Oqoe2j*7Y!&9%db+V`Nv+XrZ1@JwOKh6TvJ9;f-fNW1KI?e#Ab zllA>UY2c))6V??m!p1@&L)>DQOA3$TZ&88yGb}l}VjT9YG-|sQ5emI@cg9&uA6df; zB~wsMo;lmiaZLpv{_rwvvYPu?-4Qn6s)cyP3RiYtkGGD<)~Cu2A7kUa35!uOxCCw585j0 zYx;W8uz5D;Mrlr6x}Y0YSy|1^Lu;aZ!IEPWx(IBFw5&M z)>H&A$c6QrE`B5B^hSRn<#>=HD!?J-Y7rpHF)>s(%AeErx<28;0sI;Y=OcdBk}}CY ztNsk6&qA+odA$MQ@VCT>9XY|E0sYc0{G>M<8_)%5>{C$|Qm;X~OTnf1g6tD_f4c%h z*+=}~A~ZrhW#{Zo*6Cxm4b{Get6~M*2zrNT25q6P?Y(|U0M6g;&Ua0T1oghSm zXF@c`i}6D#jr`EIT&lC}sB9`63nK35C-I6v1(UcXyNV|{&z+fWkwAuzi1$ST9rA=| zdufmMg}|ci0|uVkQmG;)gEJ|w-&B^%-id@@T4;0_F~5`)G2Xj*1#+cX7OTHp)#VVJD{-#^^|D zqy;rxd?Eb9G0kR2fKpsKAj3_+BI6iD#WLgYoREuR-a7h1=!~>UO&xgW!ZT3pn9ABD z&6LQWAl$}IQz3Ryd?u$yuP*5R8wm$_=dbb-T#OYSXRzo<8w85(0vCov-)D=nb#o0u)iYWN-7sA?LJp3c`Mz?UCpJLP| ze@-#Zrp0-`8#B6e_pVsr>NYEl%e{{u@zXx0L-Ar^!tQ9+AGtDr^^@T_bYJ|UbHV4fh`YipSe$_Oeg6CI3TK=0eTVN(RDDnEH^xNQ8Er~&(XE0d;}6z!4a03irC zj1Gz@=4GJZOQeMgPQy@mgT~aWop!kQ%uVMk(cJ)=1&Os3I3-q}j+px^j^` zV|gY3?~VUJQEw(5GSM~nYGf+z_Uodn;;g0Cu7yyX#c5iT9u8DyI%Hy!VJS#d;ki?q zh$WlL)JoPV7K(<}urg|GK~&&Vkzr`GVh*?C^^38ZTr|-5IyM$IJHZqs#*RHvkaxWr z7)vudf9LL`O+!U#x71IxkChVYi2PKmG=d^gvfs0w0t6WWYf|bU z9M^{kV*3Ch47Blrxk}|4(n<|un*llHn+>wC%m=_vzg44n^2y=V&(pvk++rL=<&hw!-GT)K;SJBPmC7W$Ts};hpk%pF4x$#3_1E#j+!a>3| zCI57W@|HJfK49Z^k+a_o6?U)X)m_+(*#x}=Y!t=?z~j6%7e9P&tebXOZpDW}egar* z9^L6vK>6sxoN`Eglt@uokM2)NWxBim?!frbPgMXG9{WWtt4szR^LV|3RZKF6$RMw|9Czw_dOQ4_$mn$*%PY)2!=Ywf zLUq-MG}}gn7Q%F%YL~u<;YqFh`IC*?FAd7Np~>7&?cl3f8&`g~UMMy7b|qu!Fi>K3 zwIizCzQ7Dk)x+y#hs5}baS zGEtXP+4o`U?}&V>-GpkwT`=iiMEZy#>d>XU30=OG05ztL`f=jBGh^%^8Zo{uK`i5{ zw12=_RlldJ{U!)ck%OfWg1q40)7d|X#*M~MWqp573byo^Yd6AeLEx<^pfR9VmitgF%| zM$|S=+iAuYqAX?2jb%G>YklNzl$b4TTo|LqPD0!0H9M_-QE9Ta;{jG88a18vCH8xn zES82zaHpp&HzcK%#QKd70ZC$JZt*yT_Qlz-3pp9(y4X|$#bHmnxkA6Pu1m$RGH5L3 z(DH(0=e0{qt&2q8ImKul2t#6HzSa{^O35rEXO4*!L7!PcZMlJWdB>UOs-eM}6c;V5t>C_=_HY7AxgDya-?%dmF zFc27C6vz-}AaaHEn2a^1WKh}v$c{VEH=l^OIG1cDT18>;b*)Ix9XE)oY*=dRx6O{# z<2RB8@oeEmYc}yM*_C64Av*3}e13b9i)us0S+M`)a=SZVsPU83xx$cRv8YzCIl8hO z`^tttP!6!$N`~+orPt^13+wHs5e6tnEpRJ~^OvMcPZsRamw0qNzr09N(tic?o#2tJ zV8X|hO+2kB%px@%<(P2#Wy(pj5ssuS((aqJdp(49xh{T>{dlwQr=>OTU-hb~tm&d`3dO zVM7ZQ_k_7|xM+){Lj~7{XvqvQaJU#Tj~V>Jb6HJouQB~xFjsye0{@Dt7RG^0*v!3L zvM%&9Zq9)nd{xV8l#Z8_HigxA2x#5GS?*aSZV@X`oS(D~8&4GEJjuA-=HBGXtQk}W z)V@BIN$#`jNcKC*r%=vLV>t+kCn3MwZXRYRFBQz-?N+<}%<`sK|w_`-3Mh9)7BC8Y0WQmQA?eBU`Bv z)cL4z8*-Z)LU`!Hv1{Y)u?#3hsu=|y6dR<6I4)?G~S{-xy5xitd;@Le7{Xe zpiJ_eiBUC44cR79SGL!Mv{VZpYvYy+ztLe&61ie)Hrgw>p0Q_a;-oq;(tJgC0vMb- z?ha;R&+!H-!f3nB+|Ru`?lFDDA-}gt5jDfZd41Tq_c;}u0iAf3ilsK-&-wc`xW)_L zSzX#5H%d^>BD>wtv3TXIFektc6sWv`d`8R-GLn9HWduEg8ynOO@YvJB%tD)(eQG_R^3Hb!I`ZTr3 zek`ME2M3BuCeV>->VbQyc7s>XBE`C8KC0jb5U08}0%vwly0FhYTOabsR8mvyU9kZG zf>e4d3a#V2-=ZHT<3NnebDl_A%Kw7P87P8M;i#Eg5C4G6i&ayRZF5JTZ?${$1Ewqx zt@kj_&6I$chsgZni6+s5l4dTluu=0YEh|iVP;wT(Ub)J$XF-X)h6SumwUcGc#>#`N zEg@CwzDc3X59~oTdCH)SdFG&N8J|bN?5i!xplB&6Qsqn&=usT5uTc^4J4Rom$zr_y z@VfEQE&CXlTF*@5B9l;5?J#lDVl=~#_*JNw}a`AZXJBVXhT zO(@Q7rdHT3$fqz6*DP8kgd~{!_5eCOT=Ne)yj&iqH;DPG5l`o-4zN_%!vq^cY;*i2fXi#xD{#Te&zk&Ho2gKU1v#pA22ZAlA8 z3f^O#I_&)=ej(;(na`@e4n|k;pbaz&#@3{k zB_mdq39Uv}g*lwN`MQ*SrU!pqog6~!8>|gyRx3chZRIRy#A`udx3MeX;h0RAzL{V_ zoFT0x&{xSTk6URh6c*E{m6O`^<sgjA23)4iB}?RUiZ5Lt8(myLxeH$wo}d5bu?c zJg$*Wq)L7ovM83aL`OP5vG8a40;wS3LpgxCqq4cxtVoqaw8iMzprr{tt-3`PJ0ime zivz_vX|YuBUn%c@plkX^%CiC~4`At{@OoFAK>FUr^FD9Dw~GyXCM{;ZwBwvSab9>t zh7pNx_`O;$jU>$ddg%=bWVBO}JgO0V7(_bDcI$s6S1h8b78+_lF<{1-Z*H4(4;=Y_ zi(*o;kL7RXB4ZVic#YC!PYM8!^x-a=0LQ(YzF=4yfOxd%H(ip ztffl<>W|>A(SAvc^lF6k(@@^WUZ%lj9j$0z5 z$%uvXrBfIaOxA!06u1F?dEZ6*DE}Z=gZL@H!tW$r=ytWBiW1cs*`!Me?O3v`OF3jP z2ZxjPjeg|45dRTXHx7{(0NZ9?v_Xsh43g=6J|=rNG68J!Wt*tf1e%Y^~l@KNZ)!S*Yn{Z#R{{tW-fV3n4pj4CTyyh z*i(q9<6P*y5T-93ZDmu?WDIZ@pzcmpiuv?awhRr_I?>9V+EdrRo;zsWXH${9Wk)81 z-KfUazv)~fOWKURHzEn1A=UkY^@A%yvEqlS#qA84|frZ4!T@?xhct<%K0l2oky;n zSgm*fch5jV72CWi@C*6Y5gh(FzS?}^>S%^OT(@P41A%^pacKR!O*dOqkFG?zfs8MG zoDr)M))ss{>lZ72@XXRjpy$0{C+R#+?JDA(EknK%Z?7nO!C}kBA1qa5A_gwgwsZtFj{y58E~g$0LIt4!deXr#Q4mkMJtw2MBG{!A8udOH4Jv1_PYqFD+(ewa5zv{ z!(qfIn=M+zRpw}d3Y~M#J~m|o=4E+g_AbK|xtzgjOd@lgQIsMaWp5mIkKUE6wrPV= zS6xA%wIS?tj6dmi6T`P6@u~SA5fwjCj$nYceo-kVDHn~?E5F>qET$=Qzn_IA4svhq zQ8|Lr@P#5eDz`XHUA)V1!BJzZ(Pat&z0aB(J%N+L-}j+K3*Z#avfGL0JOZTQ?F9Vw z{Lh)H)&sU{^u@ly3^#4H9HBrku;_6-qxH$Ji#!>?UV9e7snDr`8Zx6 z^2Rrv@3fk1?&7~g7ByIz!Bpv>(6KJE>eOrW{X)~cWRU7ziS zDjiMS!qU1yz8)Q&YGb!%27jhf-9{PDZmm}0Dmdb_5tu(2mF2gv953hl=!Yx&DJr1!BcsCJz`0q$HMCFQBDu#UgT=j%6_$H4uJ|`A z{}Xb%qsf0B)#tdFb3m=pcWOSsFab5tcvk@ESJk-1*f%dztcv)oxJt?h3ZFq!xto`R z-lIrb04s5}%`I#@7b!1sQ1vZd#i8uRWopdxT3v@kz|w52o)CGjDua6iU;}piv}SUG zqn0)}*4emsb`-s`C{7ZNc!G5{sXStmgb^>fLGv|s zkohNWg}NAU?0#+&70sxj{bs*6>ayiZw*L9|w#HRi7* z9_mK_%0Qu_vpABFNgs-a#3udWbEBN=?mIIH+Y4csmD0;qtGV5HRz=)XwjGrs>1pAU zkcT4p%7U?KYiyihz3{F{8)0MBl6`itQuj)tCLy+nLvwBUnxINi_A&_SQ0dHwkY9Ec zB($Swx&#aDK{hg#h06e7cM2h_DQ`x%ZbVrQ@2LB&5vD&u_cVUR<^Ja#lf@g?c^Gs^ z=aLg&XWH}ei#)`wOUXcBb%gcP7|$=ycn1MQ?EmWNrjZOAsT*Vs%;>o!f^Mdn<23!8h3~PV{at!6Y3!%?PsN zxHj@(gc3$l)ZF^hcZ2pi}l~PZ)?(-1}1yCm&=|Ii}M6GBl zg;THDeGc4Ni_uT#+T=!jKvoP)ZrrQI2=%QCCixkB)`-zk=)u!kiNhnK7|IvKn%hjZ zbDlfCdun78x7f|3KDlLkxPJhgHD4Fe;Z|?C+1qyJ0Y}Hmd%&4N@w%X+wOOj53KQ6J z)@%FmRnG$J)n*ZS^lJ!w7hI}Ca#D)Ap^wH&8$q@nXrkX5ROwDT_{D2Wlaa92SJcE0|@pI-yq3c>)lf)6)L8zH}#q9_1eHenK+I%Hg!5T31Xi&Iqf#RCB^ zD=*)<5c?{` zdQq|-kNdRxweG?eR#|tx_8)|(R!E)h)MEfbKv{l;Cz#)sMo5GtfN_@CBaco`!aQ5qG zFZLY4xUQ8pAY`3eC)GK!&L@Ud(5Xrb4V=kyvW&i#{U(9BLE;eqUWJTdLmY_!tQ{iD z5llXpp+!k-wP;uKd%w4a&DlK9TW!Ys`U{%u?(5S)qe*)|zsBct?&!r~4cG>T}3;n8EWh=Tm8F+;RDH%2Zf64ghVn(Zk zCMWAac~+@sxv8#R3i6fWB3%h=D2XEM-~WlQM}zkP|JkEkm>!0wQ*6IbujRkGas!ao zzsJz|1pu@h<1I(e-$UmP*^&Sj=wjq*R3x~ z1htSCT@SeanxOwtVE?%n{O>RD!vK+nc*1r5XA|zAh2Bg*(UqAku3 zL>jGR3FmK)3z_UEq8*d#Y3=Ucnu%{=5FR59gGFHQIIdde1dk_Ao)$_73n>2ESp1*k z_t$$&6JJX{hot`{x&MQ||K=$Wb*Z0U4gTgBnSy~dX+P@UEMob4iA(+lh&qC*)&G}; w#y{fr#upVx6P(7YIlI3xl^!4Fu(0vNjBYf`y_eNC`nfPJO7Fde zqV&*PD1pEieBXWcKKtx*UElZrcwyGcT2EQCp8J`Zdu9k!QV2G+kImmWobzh zN`8nU*ZV9YqiKBj(cjtx(TcXEiEA`)l#w2s9=<Qc zw(7NyKMxjvCmqA*2%z*T`AHO$y@_%#hwg8Ydg%J@+O^y-PJ@Sj3BfWyhJVx`Qn~@8 z5XXS6Z=|ym)2(*w&AK3W5pb5b?-@kZiq&vXRwB!%z|;Ri<1oC*))+KF(rAM%1$ z>)w{m75!AzDcNgPyCHAFQpPlXfPY+=N2S^<^vxhHqFYpV6jx)-GT<5nAL4)Zi1EqD zF7}Sc`C3X<$J-#(HciVUKe6X1s|ICuMo=0tg!L2q{AR$<4{zL<7qxePSrMGvsX0L6 zxP>3&KM0@P^|m$=(&oyFEQHBKOV_H z)fUGWJ2Hphui?RQ(l)iwQ?yi3!DGc86XRXOzlTSFJHp5P!way%Bm8rWhsTWj#KR+q z{Den@`@DzyRZF|}kE=JQ(g^->jJI}mqok&^q9X27)9kH9bbLT^_bz$ zEik+1kM&g47^EHFS}+K5a&vM&7Qe;7z##V4+!CZABlmZ6+}ZQT)-c!`5Eqx5n;WMa zAE)D6D=r=p5fLtKUM^l<4%{6a&K?dh6L$^=XU4xe`A0u87S3jGZQj6a932?0`ZY0i zbb&p8{P=31|N8lBo)+#l{~XD|`R~WVJs{WB6)ql5Zm$2-3}$2bzcjnL@>jDz&-K@E zVppAk)NI@>>|e>)KygyVO--Dak5}x^F#qGqKPUQEOKoS1x6+PKTtk@nKVQq=jsNxH zzc>6dq~5;_DZtC~uVenprGII9^$Z~JTN~VPCRaif$F=z%-~D~R7}u4A|3%_|z0N=1 z;@+qDEitbDS{vfGtf?Jx@$g>YDauG{x#MrbZzcg`t1oU&F-LVZsT?WtPU+rLAQtsw zD7?nXuy8G7S8FLOBdiTh0OvfppUNOcF7utp?{nv4{NVQ=*qO`+dd(|Tc&Coxk-o`I znzLiikNb-c&@(BbeA>>mzK+m zJunPgTr&n~pSAZO7M`{7fNwLo5(^X3T_eR0cqje+(J%5h|M#bZ!{oy0**7M5+DMLj__?AS70Bel|1Zz<*SPwG*El;% zWMP#Q!~bt0`L}8RbxKY+<62H^TN3*JD&YUnTNN?kbQSTlHD0|?+&z8$|vXs{t^l){_5h^o446? z*GN9TtE~GZHC(-jd-eQ8gh^m~(#A<)UZ!kO#UUHI`vC2~7ly($e4!Yo_Z0vVDs!uV zsJzkdk*cuvj!zgDqYLcYX~bfGt&R@pslK1sLsawxJ4|kIFomF% zYRZ|DlRhcPI;_thiI|kqoM3}oM6GK=eDpmhx8$wJA5mX=eRql@!Cy*LM+nYhxv#01_=E&Mxpzq^$JE>(d68q~ zPkY~7NbS0p3ma!I7wq73VtRFUdKig-^ccp6#Xu{<8gl&w$;kCdX?2m z?z@S~SEW0i=%>2`o3&KkD~^3L)YWb`!cZ}Y-np%SJP`QWW?ajHNwT!J4tsAZgD6rQ zimZP?k{qtz7W(lSBwT{`!}pLRe%bjilTtF9l)gb%lKo+$dqbqByOU8h1Rmi*3e zb=0=L^x2%jlnrQZ#L!MraAHy~_ou|f#Qqe*nZrxdy7`g!)oL9@O-@T`4kmtD;K>(< zKJ`iT5>=JoIC@h8<9}8Jg?md6aBMt|$G$8n>sPHija7I#Z;#?{|3(6tTd?&n?rY07 zgUnAMI5gsWrq)%>)?6%0W&r^&d>FzV#d##ux{NvZZ8&4TZZpZU_y-`AOAsLTBEg=V}lqgi0FZ;zFsb+DtSV6CL2D}kI_^$*X!Gtr`Mff}i7 zmZM$S1t_=Mj>LLuW;})dU6RE6gpFF94)K#2CyMJLMS*WaBu4|xZ_)9bZCyaD)P<^p zEDLpt*AM{%hmOTb-#&((T7{(M8#M;_99K_?GaeY}Z>-YtF``C%{6*D~N5ybgHp(2b z&Ar=8iaF0PMy-_lgxd`!(zed04!5n0AJ_}|Zm#io?$3R!Dz~o*I9|VF)_k8paa91f zAAC%Hxn%Q4#!8s%N8Kymph6#lRT@J+?P@EcTr_hS%RlCnXx_V8y*I%e)kPyihtE@Q z161zWL4fmv&OL|LKkbPb=7TG$ zQQJhs{L!b?F!qR(8oZG6o+nt90ZO5#o!eW@S{g2G^kmDl+sQQg5$mzWaf$)~nvb{y z3pK|kv;ot}FRovIMo9)JNyie|<#|oG^21EQ({-nK(Hw;mgGFAkuKd>aZtH5JpT_gZ zMzkpl4HY-IC4Q=I$`T#XUzK!L9yn?BNQe7R7m0|E2XfA(c$4lci@(C4)9gj0z*@0W~=2igm19b2EmsdyHE zT$|44Km&)OLC4nMZ=<)~LB_YUIo7RnYW18U#E#5l2oG8hdn9j<0O1Wp0|`A9VK6V5 zUF>F>(evBZpNHQ+x3#Dj=tdj^3wWk7mtS%Tqk($&>zlfV+uu$}FuV57%us2*cM14< zM_q*fzzLh^klU2m726uHYnyGkkTz##t7>c=&vP5C#q`G|{vD{pSwjJgEL}8Y6i6^>DvbHn7U^08P$KTa? z&pwx0Rf<%4MN}`@o825a7Uy>V! zfYWpwKXwgT&x>J0me)yNW3q|f(xuJp0H|_Lb0?59XGS13ZR$3 zY%{)GZl{=FXXax;Sns~|T#PCm(9e`3I?#d|l+RMlX06gQUy_sT6&7yu#cT9*$gE?7 z->xvziTia10#;kpRdYx^n**kml7F&arfgjiK?H?NNV|;Q@c;CEnvx)jvd5AS7{XU$ zP9ym6jYzUukqbQ0jP9&FnkX5_eTgt@jM07^mQS#Dl8G93lR>q@YfQ{;KXpWRC8AOk zzw}H_8qAcp9>KXxksoc(myXwxY)8p@55q>0g>I7{KjypPb%X(PlfBlP80DbI@fES0 z(Pjhj)CBCa^3+;SjNzNtP0e;J-ZPRw%C$nMlm*w=ei3w-*{z|fS zdvA&_2StJ1jA-qrfEXc^`G`cXW>PsJdO}byO;6s1Q+F}{D=90uY5gf{*tS;}olZff z<@}Q8otXZJ46{W}^w#{jtyzE6!@}A1MroCVV^W$9`wn5V%)W@2cq}DGI6zD#3|K{{Svq0EcQ*#*eTq6pu<&t$bCx;n}aAa z&LXA;?Wiu=S3O_erVuOLgi?gkwvgPaC@h%+kCkp7o1eALMx8T=k}f2i)(GgYOm>9L zi$`!`!+4BDmu>(Eue8-|NQTbzK9{;jaPi{~$Y&y|KW9!9r0m+V$w z5PYkMDzCydo3_by_!V}IPa;mLP5LK9cVDpb*moJ4a3&XBLUZQ+nNc|1&KFIgK1UyYKQiM>cr2Q(r~8QsKR;bOwq8x`r!e?sd7U$!^0}@p;j^JsD zC$>HPVQ_ecKDJceuxZpSbm}D-SPMmX;wd-g1^QQi6*7J*DaRU?X4CvM2>%Df#$0I` zTu>a|k@{-$phB_u#<7_Nl6HItU5ZlfI(hSs{_GP*;~tjpVpJS9p}9J4Vm8ONf$B(w zSN6L!y~EU0g;>;nK0<}>{bu-9xE-hPWL-8$l?3JGl8BIRJ$~tOzfI+3fQ6hre)`WU z=G39sk~-(eTb3k9@_ErBzZv=lIBB1|t3WE|S?rPXLOgaYR3dqC2s3e6oO9O1+mmZ& z$=PIbAHI)_YE)9XG*p?#oY*l{i&TNtA>5|}F{`mEWbCJU)2556TscIs+1uL1(<(Jd zSU+Rcp!X%8kMdh_+e!ENBT&I|#dATSMCo!wTMs)+GNj(AUuHo361Yxnx!&gAaBHWb z?2~OM9och7`&Ts+iHybC1G1J08R*l-7Id}2WM7bEXY!Oie+g~ zv8tQ%e5V;G1BKW+_sW-~g4Jgw4-=Lpxqo>^;)Xb+_VuoX&$j{Ut^c#_UaH3yK`R<_@GkR-luqhKZ@sUeCdwM7^>V8dB{}$NT zYl*#-^U`Y*a&W#QHBsnzgKCC*xVd9K!RPHFWUg+MC1YO!{@`T?6>kT%%_X;Jr=B0i zF7V{(xW=p`Px!Fpa+fYZcG;vM+gUF^l==CMN@av|meQ!#C%dt;8_7+)+I{|lg!77y zCqr78@70p_-@{5glUD|2^1gft8!nHysnGt5slzj$zKmVt>^CTh>0y>_=SAjxa9q%+ zPc3N%kpVa%tM-vu%!=YZyMKSF00&C_MgURdu z7aue+YYTJg`T%rgAIX*KX#k z5$r6}>LW&OezQpD_F4Pl>fm6+Z#)^ev1bDYu{TFlWBm?L^Rw_(aLOFZ{ns4?{-kA# zyt?x|fvV@P$zSJg0Qhw_B96#vc(!|+__rb@U9N359BE%BzeT;q93F!gG`Z6C7#nO7 zA~Ut<6_NGqS33l1>2)@9j^mBVJ&(O`)x-Q9@NfHMb553ip$X2uhG-iNET6D{v4atX zvQMTT!v)8*9KyK@JT;%tb*F4`gJjuDg+|b0^uKKd&KbAH>lXu^hi^>s0Jla9$~#62 zGAz5Ru~q7YW9zMK-_2j<`occ@+Wm&)6UpWGrNe_J{RWCGEZ;4=H$cr!h^}kgiAxrS z+irNNi^{@A?=7g6ub4YO9e?zK9UNYlO}on85^|r<}9DY|@RQ{?#tA_jB@ z)UHB5LB6U1qK|AMFJ7!Ks(iO!#UVBbKd8DZ6-r+Y%oppu%_|WeidL3qB`a`LR*F;u zbsi+adZj#~I!k*V3kC!~Q3hk0bPt}EL!BQS7t(-HGW#e{8k>Svg7B9k*p7{kB ztGeh!a;7N*Yyr63e0jtSXBB^JmI=F~`nD)`9iM<1r}7!w-fbvQVETy5fih%S`)@o5 zh0J?R^@pvf&3*b{;Y`aapcvd5Q*8EsZP`Tj8zac`L<1@J=62H?0*!KKAzf&pUYy9J z?6eqHl(4<&u3GU1P#f8v5J*r3TfqP1i)!bpby*nO8RtaigB!_S#Ln?gOUSP$y={4s z;I3-iOwzy0>Wb1B>wf22e}*6` znNWjF>>R`ZS8v&k>gXkfU;Orzw$&9~B;q%Egqkwu%n?d0VDJ&ijLH`!9)IeX(6sb4 zT7DS8`Q`O@m+Xc;-ZGRQuK*AuynK#7C-?(n0F+OfQL?g0oWAC1cLS+Z;d@j7jmUKE zR<@k(rrdLE8uYs}oO@1gU`r@$_g4Nf@;Kor>i&4%PBc!@&@M7%`%n9QB|UIGORFIB5jQ(HA+axA z5AtJ~N_r*6A?e^Zg@q@~4m^8CIi61ezo2%L<3X>h|B8+3FO3ivknraH{TRLYSheB7 z&yT6J%gd)g-xK$ZOFstD{ARgBnmtEifU$z)jci81MubynTlnzZ&-ARU+5RWw_czEh zDOsYxJ4#j$CGdW+p0g)%7DGNsj68nGfcWmM1Sg(|IC_?mnJ3np%8yg8h=Jw109o!A zWtJD@ce2D&P;2I*H8QyKac{*ijWfw5$rYkby@3j)Wl6M35T6tgJSA%S+K)snA44Ns z=T5yJRpG~t3Gxu+iq(aYe-KOny-C{;#x3y{s`U-A1~tEnl(P?wf4Ojae+VE`s($CZ z|E_t~k-wN(+7(v6a744@dt{_3nLgl1rfcw@uW;pxAILr%b1MY2lf4WYI}=ULs@+om z!Xb8?m|b6Aqt*^3OOi~*nv}h`RvL4>2<}bmgZTZT$Op?TNqRq!>Yx&TA6(@dUQW0- z@puOY`?GW zf|G)o1K9wxmudf%)$S=nMt&yAUA9G5Nwh#U?t7B-v-`Rp)V#&VgBj)yihzX{Q= zme*hXT;S}o#XDv6bA5iG)c1>JV?(HTfnUG7gi0+8 zd!pi}q#n5P$n_x^WWi1Y3?@|H z9v^bn%@7U1&&)5lS1JuWf6_Y95FRG^v@ zj&v7&nFX%%74ADRqwdp&a>l9SAn@HJLLcg{J5F1ovo;)YDQh_GX>po*e?z$8ROUK$ zzL;MQHbdhMO!k>0R*o=A%E2Ev*zDG3r&aDQI*VPZ<6r+eS8uaEolCG)L^8SbWefRc z7g{#9-0fC_vFKAC#vUXLdtH1p;K+33Tu$QL4L$ve5z-V?q(_Z3VZY?PxDGnxZWBz)Lp?qx2aeV#IQ!QcM_4%(GIFK5Jo(d}y zz1N0wk%d1BZCn=^nzg@KW3r!&*2$L@%vLt*ZQ>{r^(Re18wzL|w4(+{pB{Dh?;XP; zVmP9^v_}cWAWqn(e_-v^uu}5NBX#3^|2Ch^%KZ3X_C%NlTQ+zvajNb#%_=i5^d-*= z6!%XJ;18E9IP2Ju@IJTiFjKVrw0Lan`qQBI_w6`t`@J9Su+v94E8Qn}HlDz>^t2qP zMQ`r&E*Q<;EJM#3woW?{&Gdm3b7h-<5DMJ1e3jp)*l{oCBhEIjMSqbqS?Oex^Ivk~hqbax&S*+fo<9a1xfifLT7<2EKm+mxqU zR9W6&bSCTEgO|BpCCQS;@HOSjZ{1N6MhcBYE;t+c+Z)wycBv{NTy1@Y2a~K1WDMc-%%ldqKjKYx`oQ`GIrI;D<^L;Hu}HwYBRs>uLOzB#GzLz}twO#eMK>Ww_CW8Eaa^z$uji znXenYm9&ku`A7E9stJ5ogR**kjx((Pb)#g{G_X+q*5RZ{jXFg*nSp4MLz5%ufvdx6 zH5Il7uDRR80w18XlbMebVrq*ZO1JugUlX#c!^_!_@LN;jm5h>|aRP@1L^46rbxW%( zC;rA)*PBpzot$f`BiT%69a$3XWfDe18Me@FiFgZPF0|tiX4qLVm6S$v;#eo|HPDBf zS5te6a|cJQpA~NHk7i`?oD-$S@-)1UPFe;;xr+9#ZJu02#{Z5* zS{!Jy)iZ*+4_KewcnVlCT$c_Pw*V#CyYPpKJ$rd{x!7r@bbV;3_Ti*yA8%{h_x6rN zy{UeVyLTe@_w?xlv1vQC;ive#V=@1~_h|%XhyxD*9 zAE0NBX6f?C7*95!C(64{30wHcR4}oXy^!8w>vDVS#|@zwK5eRWboD^7#te+$+Yun0 zYTK9wgUH#z8gXRtNbpXKAD>t+QmA`mHQmZK>yGLX8KXyab z5rq}K-%-R;;$2gT-dLO=A)+=D&U4!%CZfOe?wwlFABl=Rmh3z@?hO*@)3G$f9OgX| zHI3*BAgp;q_+=_c)@L&7*Za8GfVmhGq}-RB_507_$(5H*SAtnI@bQL>-;}(H#9UW0 zuGY5aPri)5JEU|WQhJ9+c>l$KQ017&Uc)b4euIl*iP9DSSdg4wU3YNywhQ%=@Eh}o zx3PYYrgh5ohx{bmQT&VfZ+#WvO$ad5CV!;mxqO}+NvkytC|y2py)dkZIN1vHESYX} zaqQ;2Co?ucxFqcG*~GVtZ!ALr;zE&?=p2~vv}wq`4wp2wLwf{^bQIbM(e{p3(*27A z!EhDxsV*$<@Tj%HzQyp4j)PPd&e$~NY=cO*8`=HP8>W{CEQPXCltyt4B8JPsdE zQ6nu844p!$%GU*DWA7MYeOU8A&x-nPK3;si;dK`;@m{@#m5?U_daL9TdY1M##-mmaKTJ$&9>&-nsw@wi0&;N5?=;x?sgk^3ixx{e%!j5OF8mlF+kb( zblG$#;JLV2H8sTe;|Y0d^Aih+&ti9|LF3MbUBXlWq$3q7#DIJ<$#O)rWGt;sFZy&E z0w&tICgp|5^IG1?)>)E-eMQEYz8f~tzX$xW93j5!`M*K3<@@-*L6Tvg8-O~;18l^{ z;Ph~5l*~K~u=F(DUu>YQ{Q6DD&ztq3tt(kLmh8p+@moWjDPQsPvBZy(0+PYN?F7v_ zVf6Mh8;Jy*yS?~bF+)_NQ4Xsb#5d*JinD(oIxR(Dg!07{`zb7*`$uz9Q+S8zi+k~* zf#Xw`ChU$7gcH_?x5jn+eTBx1^7cwYEkZSXmW`}5E7q?0c7lb=-1y(5mwZpu2ID_) zIO9}~7z3=R1igP2#sSbu@g7r`p@)&u=KA8Iz=@s=@r+K-jSOUnrqJ{TGQftMuf4Ki zMmkMSDs2u?gkNKWL)IQ9jp~K1y&v^ayOY+RUu=HXM_dhLW*j1rp}dcCuBB2evJUJB z2}=BTb8MzUpVW&1pIn6Wuq?a9#r&maq5F(2Aq0}?T4{)1bmm2Z6KaJB8%yOu;fHq6 zt#|78rW~}uzfNNv5P=foJ6eUxk6b(p9{lRnI`vs!7BQwx)9L3Mp{d4NNN2Sy`UlWB zfP3hmUb~YFYY{ue#&_>j(4oW^mQsA8mv<~l@%C?zMWF<^zrXkGtJ1u_Wuas^M)?)~ z($83|&)0J|J<-Wn~TDM3v&KHc*x{QMnk)mX- zC;LHlVhf`g`JUwa_Kb#Zxgf=tk3Ol~;qH&#sa{612b>sb$qN`lX4F6$kQb;jv4Km= zo6j+Q9&?nl`r_gUn~8*ybKgZ&t1~Wm++LP?(~44)t6D!jzP}MMDv^wi6-ZBWYI;;I zG8+y}ee03WVNJ7E-rw=z$@C$gB@NoJ_LkBptsuGkC>lF+C-+ zPyx&q=-isD5U2*Q$3C>^j~G4l$i=*;U_qVJY-#3bH!|y`$NFz^)_L$79}RCq1J+2; z1gWsDaotCrAtyo~R6%tWhSF=T)En_Yq!TA+vVFzI+0!eym5qzN41JDy@F_?)`uYHp zqW=!Uc}1$wZk~bx)Q!%5mKgJVXnWkw2F_N(bn6Vpx<|t{a1Jvx6ve#f-3=2~gvl37IB_dDSshD+ zVii(&)I72-NFuJZpJ8b$n*hvoH`%6IU*QKGgmsitsex6cgEN2D{{(PxLx4-}&4>Xd zWmQh1uSHU?>>pE_b|M;R-CLA0o)d zwzlAHu^=7g@~{eMya<985H6q5TRI4Euh-T%NE^U0r->*D-rRPZglmyN{4hJ>o0LdocDL+MM> z3&vV{yesR*i~W}mPd!am8Gc#yDmLZ^OwY;XMxzPwP`qe>-Tfnw%l6b<)a7gQk0fwo zU}hnCiMtS1C$%1DDMa<(^qo2Bi?tdGw$fy~gu$ysc@G)SNa7MI^R?8`Ds zyjm%M$|Q~(#E!|g$6P;4i=HjHi|Qc^#&hUrrwd=C`dlW&Q2)vD8qSqlmeXCuX;uO z?dA-lo)+&bHH@3Z(42Tf>JE?~{Fn0U(u3j_@xoKMs4F-q2zyz( zKdB)F zb-G+rk}STR?)2KYgc|wlU!;m43H_DDnz=mIVQ~0t`Y)SR-=T|#a~x6sQ%sSEd$}yzzk5UvWZNCb8P@6FZ?bp@ z=InS)LDfYUxG`4qZeZk?Gh?g0#Dy84Xao|>FylV>+WMECTzJiZqOC025@H6IL)84)e2Px@P>#zZt`ZQuLri4Z4nVrR*8+s}K%>=n)88e;Z|%d$@m*V?^hjw7QV2wM#uuOL3FGYf!5(*28hWROn3^nWzRr!j~Cz_nuw>CR@IYmNKkJP!$ zCu29hvdtGFW9jZ*xt?8791g^QR!VXZ`#|n?oD4+{`bY-Zc=@zZ)QkXoG+v{kJ{IKK z0!za|n(ttpku9*sS%{)p&lu!Z>Cx4LoKAW*gaF@#aHAMbP1;@8$D+N?@e(l86>SWv zsrx;uSIYKT56jpPhPw+^S~o=(-r5SMmC`y<597V}4&%yyA^ zQ(Reo8FjL_EgYrU=TUu&dH5>KB{C2!5siYlg#H(#*NH8h@YDT6(FuSnO$$)rLf%cs zvC@Pa_6@n;nk{CV2AA0!X~9(98DAZ66b-Mg{p-ZZDw}3q${}?^f)Av8PtlJQQWMgy zKpm#o!wl4=r=>Bt@t3~|pdx?(OUomBao#3*k$~fH<|xj;#d({caiwLRy^1lRO47)h z>?^yr=HoB8Hhjn7Ze1S;@h750$RV-e>4cennEB z;z;VzuM)AIA*QeQt)l)LoNce*Og7IQw2fVDJ4^*9NjJy2P@_?^kMw1{`|;-*o~NtQ zjtbr9d;Q7Hm6e)`a4NbF47ZYcV@8vDKA@Nmyk=MLy_xPB4Z`j}TFF+MYsXo=ABgj# z?3)VG*OoHCuPC$rBCXYnDwBl>#|7(udNVwXJ4X)M5PQT~YTytZ%Nf?~-)pjX95#DS z5)ZMp4;Rsho>ug8Tl$itQg}RA7*-l=^r=7Fj1hE$&$*6%V{sk-Ag<3xOe7c}elo*M z`mv`?5iuW3erD2?<@v7DZ;h1KlE-n_Dqa}D(I(ZMIMvjrXBxBGu*LVOgsNj_$@U80 zBl~NaA<&Hq@2|foy^DDuo#D;%>e}_Y?dq_(+)tX>m~0i62c093F;U54 z@%HATp_}=djm&hNt|7OKel=UO8mnEhLWlOz$yBZ{i`Rcz2%qc@MFVq-H%67t7CTVo zI~=>$m`~{C{ejbNqnE@&zc-r>cMA%oYTwk=q?Du`TRJSUC&lzlNj3O=RGpZ2<~2O) zqp(TUFF<^|)xOjU87|>Qj0nWqEuW$MW=`r=6)rdD?vdIzpx^W5zbv-y#4%r&pgmi~ zL^}!ZH9POBHk#l6rrC#{*u`%rgW=zqc9umH(0#c-A)zi>@7)`!vsKMhH>AmW8%ksalurt+tn_C;RAX;5qgh_}G^800ft5K0j}^_MgU(>^at}99koD z2|_r&$Lo}Mb3G1_SheC-~Ps z06zQ~(R4B67=wX$qwH3O{$X#2J`01=r~3z^nI#a5xI9VbKFmIDKMP>co9J-M@$$T_ z2ls85-cGs9$X71jPN&GoB)&lmSW&-Xn#S~YhjehcAIkcIr#O8+_>1>*XnWgt$_*Uf zgmDjvHg^bo{qA+KM-)9S;M?inOEy?;#2OYDTig`y65hZ8Sn}X3;h5b2)CnCguNE<9 zHvx5?8I`bfE0Uz=y8%wB0Cb~yKsR%#Y*w}QrE5C=cQn&sxtHm+->HC=y;$-|iRKx}g}7}w#J z{8J~b4syTJWm29N+c({6D<(~*JA5|s0@Lv|5LtGr^X|mX0TQX)G$>v@l>F%=#!Nal zA7zbbk~$-b|Cvw~$3+VeI2~AiScGD5W;$|vumjCi&&{G#9fV#vXVkh!qZanK#E-iG z|M*mcFtii;ok#D=Vx2PGo!TaT#U|=UM?cRM&@WgCVsW~@ojvCXJVtV5LYYWWV@{by z(ClW|E6WPBW4$U}bJUm9rw1SZ`*t7$i03fxOuSo39ppYV1{opbvS3Z!_6+2)Sv~V? ztIW$R;?}7}mYHxhzPTJ$b8cXzD4%~B7qm^(Pj=lBmGZqfJ;RJjtbdnoD#Ha@p6{Zk=8lql@EH#Ff3jKk zOqaFn-=YP54^1@qChSiAV|67m5%RTsC%EWVr$z6F!+{?{KU)0hKD*#D($dH3;^aPJ zb|Y@Vd7s^zG7Z_K%rSSP)TI|?{jG7O%}EmVjtSnz)!(X8Z>hi))6vYn4c7g&FqYig zifI?@_iY@z6$E-RCV?~b*@E9`h_AkTVYF<~`C)s`xKJk_@AW^_y4*3%K=h8RxuiE& zMka1q)jooeLtd=i;l9SAG-{gxJU@H`y2RW~FRt|1SQfgd?WStv*$fCAz;=M9j5N~q_DG5N+Uk{+wJ5BDg8(c zt4!(L%TI$t6$+um0P`Xtj|#x*!Z44`c&z*yE;!UuFW$sA3&Ct#CZJ=xm20pY7VTu) zo^5HtK&xA zD96o>^MX3cfn9tzSi?q}AMN9^vVxDg5Cyw@`l!I?8M3Xb{=u`KyGLuPE~G)H65gw` zECr)NkDQS>8sWCNf>UCVYt89QqbB?!l&&D%t2FudmK6HOPR!|q$56-!C&%7EUvLOm zX4wRE&-g(==rc`D%vWUW5!r-f`@L3UJ`P1&;vy(4z(b0g+oC+3LO!K^P+hYL=7EcN zkspz+EFGWX#;(!o@$8}F zf?~631E3c!)OUwtUf-sHcyM`eG1IMCsT6DSV2{hgJsZfk#}&|fSVI&uWHz4y4o-|e zIgbv|9p-XyADK6uXyEGG(cSkwtI%EBTAdip@AmHZaN>Y|Cj9-tCSv+8+u^~mL5BZYoVt`j4v$L)iy;i3sfA>xt{xd)c zo~h2AH)Zx zVF+0GYF#bjbq_7;w-PHm=QTaQhMlnnMf2}7B0H8;LR?1~-i?QjjMcyze1`5j;6 zL6zVN8UwjGX^49NHGz=`oUv`0h%>f_t?tPDRkR)Yq+&1~#X7hHx2dO~?XP~eZPeU7uW-%bt&GJ?(4@}3#j zd((MjIZxJNqc(W-78&CiQJ^-~@2^-wZ=XnSaT;OtXEI^-=ANAod(m&7k7s%^lmlb@ zDS{*m5Sz`1i4mhdakMgI<{DWqm7K2BDicnvuHwv#zqIOqN3t^ZYwK4AM6px&uW?)f zr~D5Dj8ElwQm*gNtH2`d`R-WOWtnH~WZmhDM&v`q4H&1r^lXqOGNVMd&~B{t4ttes z;wift+ac<{;g*symm1p)nmP~B37vg;eFbp*xb0Zx8Vzi!R=zzWH^Zf9vg=d zR{j&ACFEVgnS0JXUuOofo#p&YA)e+J=pG)F!-QdZSy^21Ewr2SJgZ?-l`(Fxd5)Mm zQCxQFFn8j7{P7zTV9MTlR1 zTW3u{c-pM+6{2EmdNYoo`a@-V&l_HUb79@>w+#JSf>oZN$pk~a9~4XsUr3_NfAEdC zd>{plCyGr@N=Xm$*jRS6$P%@HC>uei$T~$$8&vrqoDqyTTDTa>ciFZM=goDA zNM_^R`!tOR>65i##(H-jI^#0qZm26)Bny4OnrEi(tR{+uh=9emG9(W#>BWBful$`% zJ;*K4&7=67`+9FQSjIcLiz;%!B9(beBClC)HweO_TTijfrVFEymd~sZIP`E%{yE(r z;hsGpnu>Wxwow>Xk}*Fk(jnovseI3jyC;qiFzOTei)3rHy$lF3%C2prt{k+YhP+z% z`sF7rZw&YLl1@eCC3pXn z!KU4{e%d^uvC7%5p+uwM2K$f2((jqbABrI$pOr1PC`Ho0Y`5ZSh^=9SFIkRv-PcAN zpNEJPg8@D|T+^zT?r&ZtZ*2Shk;@D6HwOfS0=gZ4N{;zSuZ8e{lYB1l>X&PBJ+4jA ziO4ly?6Xt7o)Dg$f31a^{KKy$j{;sqN&h+@2u_RQD*7gHL|~k}b!{`jz!CHba7~5a z{ij_~4v*~xzs-bCDWaT!|54`s-tzPJv>`)jjbh_+KG?P@t`PkF`HRq=X!@NzG zGgXbDy|hh+-Ct7px6*XtyASn-18;ejZF@)5=YPJd|FzA(3F%)>!|z{%Mm}Cz z6DAyf^yWe!n;-LaZmxKX_ISkXxFNPgZ_`e3D6lWj?w^JKeRtkx;c^|<_$#-a7XGED z!QQ)XoU>LYT1vo)#;KsdfBTC8G-UWf%?@Il2Z0qM{@&{(9bcpwn(tb>O1l1|^4lM? zhhu3@jk&Ih6xV0$if%Uq|5|(2n0K7^czvijeA-39MgH#+?vOT}3>eqh!b4X2;jYX& z*VFzaoU~}3oMT26N6U!su%l$G zyzTSm*Ls?bRdFER9~iN)$JJ}xV3B13P?9*2#Q%KkLgS}9A-Blz+Ri!TXL9DW7VaUL zidOrvC*13NwVj0x<=#S9wK?NzeEG|B&?Pr{L9~cQe; zhGV?z&0?M~;oE>0mA5NIkEb;fE`GPz-1S-vd^>Gi&9&8-#YLI_uAb|B^J`T@KenTu z5?8QYomY&@DmuBU>8_@IDetC<9|&lCdp6o}*>-?1FZLhonzm-741fKm%QrYDg5`L> zcDCHcx8Hvg7uq0a<{023G!dtR;gb)A-_jF4GHXNqI zYNRGEF3uht{vY<9i+E_^cql5s)XKqC-fRR zh|+s+p-2gk&^v^-CqD1N##+l?x7T%soHs!@5pU9_ zZcW{z8ewPm@~EcHKnvlO8r@c`H^4-MkMR5#*|%WqBXhOlL_+uliT55rv;8IvZw zsr_fAQ?FP02Q^&OzDI)17 zRx9Vqm%VP6N)A5bVUbJd*6Dr3Z$=<(0nHrIxU)R`Xc1H0{Z7kyv3b2SC6@HF!402{t!Af3PRa!am6l^cHXbK()IHG-)d6ab zyKF1W4o9jjG1eVWs!XPBT`BS&XOB@e&be^9{L?Y(L$vTgeM#R@e8TatmS`%6!)vyT zkC8rUWiql!S&qX){jpXL?qcq3y#XVtk2Fg(;t;>(@oYtuedLDs?r1o?CBJzyut{Ln z;wzy&J&DBc?`-}6Yh<+eO&1a9vmv9nv9RBZm;;u4RCFM!3`R!@T+HA-hHRtpzF47` z{%_IC!40UBCok%(>Ux}ti`<$`dZROa5R*EU3UG(fmGY|ft%1LD+bd-y{+yFQS_a^5d$^b$gyZmQ^3

y(*9ez6FCwz7B~-x%B_k_Refazl?QIV9j%jR%P*dhARn9m^2>>z zcYSaRhm~2X%ysS4y-iUKiP3{JZsHaBI}-c*`o0=Z-WnadQ{2a&yf1~ zq)yiBv9ncYpO~MNG%l9CFS?yMvegJEz`(!!vTTwOZet*4u;Q=WgM&nkfB5BtPOfQ( zv@L%yp3Thr#|$HQGJFai$CT-Gyd8nDAoln)k&J?#0#b$g(L+C(-ucfF3Zq4~v`%Z2 ze$IK9Q-v6(v;JSS55Ow53PD84D@}3ug@TS!hb5R`#tI<-KsOkjZLp!XkgJ^|9CHE1 zAD(FOf$CC3oVSOew?j4+D1)X4pOW^fN1KqglBl#@Q`)D306gx0G%>gZ#U2XxbUIz}Z_H5|B`OJD-q$U)t>PH&~$MWf8Z94QRaar$)<9M?;9#zN&Be&wun7j zW*fjaqR!U(-09)EPP2BXQ7+$}`P6AA(q92By*cX9i?mfAQ8-K<%xacQrk3BIeUB!R zOE^RhnE~v(@p(A3MS9w2wE3+Q&%}0{9A&syF3_9L6RNEg0JzFx0G=HGATn)W5EHvB zFN}0QJ?Z`0|1rBJeF?r<@qLOD-aw1VK}mg*)dD6LZ+c8UqE1|GNDfsW03rqOAq}A| zqj0Rton@>qZ+tftKn2v06>_`x zOhkInhU%hjmZVcezm4o)FHt+$=WzQ13iUP<5xyGS8~yg=it~K=6Q&f?Iqx7AlwuR) z`(@=ELV2-4aJ7g1xFdDif6E=MquVOl?_nZ*aZ+K6Sp{*MTH0d9xmQsmgS!puGJ#i) zm}yPU_a>W`pOrv$A;f6kF8O2mTya)|E;&Jd>MZ?Pp~y##%3!^ty~u$z*@+neX^0Bm zI>o$@=MJ;5>lsDD?gkokXw8}S+h ziYvGu6#$AigC2Azu87;qy-wLLW?{I@D;eSSP1(UxIc>YNZDA^q!9MMrK^4fYX0+j%fb<)Gf0B->WL?8|ENQQwYcmBEoywXV2Fz(583=?>;|7>jC`aHLuvn>G; zc6|%r2(d1^?An$~-!*%5W@xR%2EWp1%zd%S?n%nMbV+cI>D>KyAyXJEJ9cleU_XVE2o7 ze9nQZ``*9R;hkv%FJ~$p{{%e(tH*eKK4K5ImxKuhwWkc+qPK8R5;o8r{kR~7)`}dr;M`RN_h%lft2DS$eS@S5zX%x`IRlsvPw-A0 zO=s|c({uylout8u6OD-U2F-V?$JT;b6^0<4U97PQQP*7`5b)9xt8J{heZ|#5fbHIa zr7Ec0jBpXMQveEUj!}i8R#&;AXHS^YpOp5+{eh!V8)h@{kZXM!8fVhZ%_~3%Iotwl zLTYQS-fOp!Lxv9eWdTQ;h6F?PDs+~@YMy}-CBRh7xxREYf9EJ!ZRJ5>>e&n6ygJol z7eNZR0HWT6p7~|)|6u=bf%ov{U9PZ z5^l(`>8wZP$}I*hG6@0OZvnZM&{+92)!2R6SZ|Xgea|UGVi$$1n+X}W-6`Vgc;o$N z%K7aqtbmfUE#nGi)aGP3Xy(00=%MsfsG|_C;16fQ;8AJIIjcRtWx>aAJYnuEZCzpu z^$F>2CYcLDO9w3u1RW^l*^yC?a%Iv18TP_GGViQ9Uqi|^I)s1>ZakgzA&7iCeoMA& z#W@QS)L$aYbbPlA4D5NxB5m@T@_gz8R^Q(-qe5>iMc3*>?$g9D4T~U6_uWzgc1-b9 z^Wq#i+M|gR35ctN#I0{8s{>S3E0b1`Zf;x@zeVb^rPmSrZ?Y7#K?$P`L4uFVz_4u- z(C~HQ1jLujl&vfGsh{vLqgvFNuS$(AZ>^QhbLFulU}#|Wb1G2`X(*=#jCWEeLH~aJqOGWbliUA9elBFz>JL_ zo;9d$)XXGgJ5?i)>u^*zXGr!R0{YeQI{GaFm8M0Ns65rco_^*rc2xG^ZjD&Zrspw? z-GEobRH9JuAFi7Cpe%|IS7k3q`uuYiNl8Mv^OOPveM>{veZ>4Ki92@wW&gZLl+%7y zNB0^qN=Z|v(nJ-#Xg3*uE=~Qr>)85%&rKrx>~~@LIS%yY47Q_Qext=VYSpKDnlEj6 zh0l804px*&Tix>`HE{tAADsXrLnztrU+1K--y{kRJiC7Z#`945GgQ%)?5(Nvx+Q+o zcmVl0;hC$OIQVuJoxJgaYB@N102viUB#n>#W7ZtXeUM`b#HI7@%Al5 zz54UJ=h>$bc9~k)DzRz;=j?3IXQOdgND9YVPB-1{um#Bt5wg(P~Ey=pqU z*rEFtw#A|VemG{~0rDLRw8S;v&jJwcfTu^tyO1xN4s^r0auTeeE3!a? z2gK!p~g=zJ?vSHIi>u0MeajWaOUxXrA4K{UI z*9H?VJ^GPz-Lf-Z$sUfnh=qG6Z`%!ySC5TAyZY{XwxcpyZxwhd8|9aXs1_6Qao=KA*Mf~BfZW4FHaH7?OdOr`3!>nV(lMjs_92dKWus*WQtxpw-eV06pXr8Z9#GNjhGorctOfGFm z?~Pojvf98K^y#W_aeL*Se4G1-R`ar)8nXQrO{&}a} zNyq+rh63c#%$!@C3DvOBYTzKpv8nzAd|qw1^(f(!nx#oJ6*dTSQx045o4(%F{_aaw z{iaBFyTRaaKG;}$hJ~0#>>wO!!b-!Y49XDk*_FHquVS0Or&_SO%NbC7rcmlMy=&Bg zBAQP~gy9Vxa!?{LK-pI2>18^1f2O7{J)E@H^_B=w-5nPc9FNe6YW&cAmAqTF!|9q* zXGYt2-l6jTDSw^hEm4v4SvN8Swb2^R6(-jbJnO~7c8pg24b*;zd&fW7uo6uA8tx_k z2ktzE7NgMVfL(Rc4Ek0tZVc69gY##{U3#V=qs`923qvCwBZ~TUVWuL=(iQqTOVMsf zMH5EBvb_g)Z->CFL`hlmBB+1#s;EA~(b^N6cy}w&>-Y46<33~rR_fqN3dnlSatu%o z5c!{p#n%aSz}bWxpm7hNcC`SE`)+@w@3FS z$DM%H{i-Uq!Xx#iSGXtWEZ^;eT41am&J?(-(nBu*RdOID#Ehfb=Gk*IEsG6phz)K} z0bYp9xJdhCm&>|#g3_h)hS&*zpN($IRR$?an*lk#>&B5G!cuhPa1{di%8mnZeBB7GHBsiV!5R#8r&_u!B6sYw>VY>cT)pw)|q zvWxhiP>r*fE#+I6-hc2{sH(8yUI*&XsZ&>tfnLA8@L}ZJ%F3gT8^LqaPw>mFa=lz1 z;a1bH&<0$rT}Cw^2A$|7EmF~NptfaRknbT`H5Zh84MgW-OM=b@d@|WAkq@lR9HBco z>wAF6-HEcRCTvuQ7^Mt@^0iicZy#J?2K)k!GnRbb5Lqwxsl0nFY3Yzh)YWPQUw=@z z)bKbGO$g#EZ-`iZ7ZM5>RX|m!!k=rGJQTqT@`ANKaaWblJO4!AEb44{ngjW;9Ht(21k~ z2VGViD;1)whN;EnZULO9_+OH6r$~?4=?PVaEZUWk>Fo1IuuTakP|fO{vAodMBpJr9 z`g#Md&qxaay8o4{d0#|wU7a_@OWN~~#SP9T6wPAvzr4IKU7Hid+y<(TaHI8Y;3jHag3jF(BI~XIK;9#XvHJB9@l|$|7P%Zju=7H7Ge-~pHn&i8Iw z;U^Oz6z4v7>eNaMQwl!~Jv1cQkM#x<04Yop{dgdq%JqH@xT5j}bI#!!iGZg0E7TGC z5D3j~9|Lxv!1U^~zZ;BmbHMfaN%~Yeqy@gIF$QjzYNyo>8B&fP&tj9nptMLe?7zH?h&^4gyB1FcV=$Ei2qzzL74q$+M zMWo04+&c6D0=nceWG9}F3%8f-kzSJm6CTojpaKstXml*d17;Y4AU=Ctlmp#F_yJ37 zZ1*pxENRf=kxizBc^-B=n^S9ofIl^Ct3s`9*4}rXCVjh$>J-Ac><|w^PehfS zT!mP(OJ4P^_)2xLBDBcv);F4?YgGlFcLnXv?BFU_eCOe8F_>)qaoc)hb(8>i46n36 zMVqRz7#=!%e@gAu?0D0Ixh#8!Q+gy!kEAfav&A`fMrhj1?HDd`;D`TkoCUhI#%B$c z2-z%AXY)3}_~TdQ(_K9fv+e3*v$bJHP_q8wu?Eb+{pr7b2?Q{4;G74vDp|B4ZWZ&j zN#Il&H`#KI2N^8T#rO?A^cpc;(g2YL)> zC`&H;xG5z)XF6Rn9l-r|{&2cqm<K8T7e%i8`_7dK zkL(LSOsib^z$>#joR@$Hwy*r?nFrjeS7HoUcVGG@6y!GGUF{ zInpt$+s=ts(Iv*wia&-@!FHX(OB`-25fE|a@~1w}3?IP^PN@@RTg2YoE09zEa=!v_ z6i@Bwl6vuisg&=C8(%6u0ND?aU|#K81DMgSEyI+t8n@LeuHL?&&F*izR&xKqDf>? zW$d382?v0Ak zU{AsLXqj@<3QRWDHL)hw;c9!iK&?sj-e=`_voVY6zRtaUpP<k-?bQ8>} z#fg_1aFSfZ_c(fF1ptHI17|H-t#_F3U-taIiz>}>nN+k*7y2$UI0E{0fDRz5A-t~O z!cU!6f5KISc;f1JoJ-H?yA)f5>#Ed6OVJ%09u`2yRN29PN0U3y?T)3W);oj?d0oQ+ z&&OQ&NSKQSkcdV~x43zzJrhTL((en{IxJr~EnS#=j z6dAa~Tx^85b3~-}BL4JK9&-c(INm>}Eu})@nck+ZY%0Jpsw)vpb+`+-NEw8nzW_e8 zg*pQMViL6Xp&XWZY9Y?>Jn-FXNL36CK&tLAcH`!g0@Mn76}vd=WYBRc=Sz`0v7C2( zN_+Jg3RPR^hkkJAjaSSMDtOfk@|j$nsAsDcFOqg=Sd0Mj3G@(>cv@)GYM;8q#Ty@61yY(hjSvwTUgS8~*zOo!t zvRyTaRk&T6rbx6U{1o4j8^N~^I7#Tcf4U&HWN77Oa~_oWMtKER1qp2x8rE&}80G!E z6LdaG8EO8ra*nEN_AGR`o||vb))s;={sJfnUy)R_rG;X4W~I$VvFX^z*gYY>OdWrb zv<0W@WnA>iF-3Y!IxkXe8jiEnkGC43%j*pOevyN0Z(B%uJzbgl#RM6I_Xyo>{ z=2MzJHl^q%2<+HC#nYZYEq?QT^DADC@vvZ7}t_23@x` z>{c470pI4?vw>igXzD9}FRt~MhRN0et+Lah$-_NoiTMX3azeQa9!AeZhhOweu0-wy z2FwRqIl!O9A+0-Uts*}W4e1uHg{Imp$iZ0NLK zuNc<#>74P)`-j47kQMASms&<)t@Tc@qHzz+2lTpI z7*T3s>v?*g_SKR>fVh`P_{FjPbPmwlR7qEp_t?>PI0SFIX05zZZKzIummGl~H_YD% z79;;upz3uh^65<}FO1M{=%AO*hkSxR_5*U?@#Y6lVH+(zGlE@KeUPsAK$qhtzhV}o zo=Dl{Fg=RI_NxBpWQ0QO7(xrZf6a~NE3tKqQ79CBJ?cuVhdqKzUo7*(iU$DI`lcr*s}pN)RXS7GyWQleIHXG;d?Td^N( zUs0n)4uW+XvPO-f9MzUvYM|l+^9>Zlk$AhN+c3K6{Wo_7Ypy5e)@AK|QIDWa=k1efO-5e9-$SPJB4uTcGe%oY_72rB5YEVx^AP2xu1%w&o{>S$XwZrC0Su zBeV*}Rvq0hCuH*)jwZ@I`wsMVzi%2AOnmJawNeRtb@f*H)W_gZ8_1_A*ZH)p3S0qY zPyXLRnxr33&0>)>>0PRp+}J?2S`+Wt+k}50=ptvp+A{nnS@SZyeV9S*8=c#9NOfM z8wa4`&qV31;_E;kdrMcsE?AHVk1J|{9d^h#dR23 z90Jjp2ac}+fPyAVL~p&G2|%6i4J9)V@EK(m`wH>f>jKZax#PxDa!46o8)w5gaVbBt zkjehnA#eOi+;+|ZuLvK|+q{nMHFkrlyGsMl883qw=SOVq_c_HRS9yd;iJgj$iM8Oa%Q5uIpCG zf9lqN&{cSL6MoCnA?&Z!%xbkKB9F5d`nCD6T4JXu7D}z=i81>F^Ccs7WCqoLW21a* zI;E|5XcinApK6cRsy$(wu@g{e$aqq6-;9wE^RyVQz1%!!8C~UQF?Rm@*US0Uv5&4M z<$iM5eonN?Tlhv;RN212+re?N-)%p9u@s~$n0SLqTjdmO@PQZJ^R8SKlL2~}o5i{m zIPc;dS)YAzp?KKE=65fCaQ4h?6@<6zh6jUOJwu)NNbzB^IZ$}RH>;^+2KE8?J=kZR$a;unK{r|G z*rx0omtJS^p6~ZDmYyiWBlQ9!l-S}g%k^ni6aN+ab}LKYh)AG&VsO8$>t8umO$Ckt z{2<=C0_Q)swn=M?kg~VDiyj}`7NNP2@1)F{?RwZg)Of~`0<)0I^cH`Wuw;dX+4G;? zw!^t|yJ2OloM(8KH~K9u+13Lt-olNWp1eWFZ6cs8rj*(UzV*pr>29yZ@445)*Iy^; z`*UE`1+MagRw`4MA8ro3S#lve`6B>ej4`_gjFm))cLnXh9LWt+#Z%K1hN4sEW_6s9 zb}?W~C>pR8_j1}0JTATmuxSDhGA07B&rRDsZe!mP zqG!v@zhNml17Du?yT^&@mcooK;kmw3%=Zoxum;J|Pgt)?<@HIsE|lLHUu%#7bR2&g zkeo>QiKR?%YJ!t>$E^xNkRlJFGYYs(4Gm?js0M&DoA~>I8G(3?Na1!J`=1HB5_QSp zEO7SP@mnL@e@;nWm9*vn{DO=<^GoZ{PMuzB!PrZ{T_E_c1|;uo=?3lCX_jXL!!GsCQ-HDN_ZCJ3&@qw6J+!So`m0c_g$n@9LnAecyOknR zq40exz>HNCSXaP=>3S$|=Z_!-csPj?F!yU+?&Avu=6>3L&;5c8#|GzDGdAlT{KWwE z_>;wE;nMF^XGfkO_Zr5puTq=$c4RPZ_iX*(5F}XLaq%U&rRY!K2{(HBM^LMn9DU*y zvs4Hm&Z-TEQWlXDsvPOPic{d%D8y2CR0_@RxY_nN|vKJq}PNFN4_0&>N*7a!ZHF6&BO6udt3w>6uj3< zwgav|3(17g#=;6t(Mq2tZ=}_|r_1KSH%ppwBa1E?@_la3dzQae--n)mSvwSs`3Aqu zhy9}WC%hrv$$DS9r-Ho?U%*>cJWfbjIyS2=vN!}&d`+}QVh0O4<5@QtE;ujV5*HjT ze%QSkCkwROB!GusJ*3u#$jZ1ZLNz~}q6K4*Sh}^9Aehxuj*MAt-)NqxORB3&AHX4H z^k^28-t;(}Y0DKf;!51%$r(>Z7%=j3ib{6OIo@5>pm={`o}-|`36qZ9A{2}rp*3;4 z$X2}|Y|dq*85Quf-f403`BuFJqn$u9YsmE((Z)YCf=gopurt!tlVQTUJ|sSK(8wYL zZA11X_fZ2^Rh`>DzUU72d*L7#&dzDMQ=d&ol|K2!EP6=BD9~&XzD)tU{gUA$oed zaUbmt=@NWa6JKe6suMa%M8E*4CdO*rdo!FnV+@=PKg>N(m(lKZ=J1==(aO-|kOBWC zU>RjHCFb7*v)G!_!6BC8Wt5M5L2E)o)^y5LsK4gx>F|(~xmQbA8MyJ7(k7rReV-VK z3B$M+ZBzLGPkd2v$ui1hVcMGk%rx?Z7`^%2Ds1T;xMrO>E)sKEB(-PurXJn*F5;2` z&W11oJ_nOGRF~x^KQ=2+EhPi#Zk#2#z*KiQ_^RT2%w@e+Fq$%RRmuClW$z+TnJ(mH zFA=@Go%n2d`VkL%Ycp~hs7<3x#GRNX`22yQ(=`2wxo#LxX-_cUR`MDYD16B8IP(th z9BiP~$gDwK0;j|@qreq;`zXwx@OxjF4UFU-wdJQb54=UVtP*}v8?AUXH^F+-@0Mj- znF#jScw&(@OboWvN_^z;TuqHQ><%gNcVs^$LTTMu{Q1Jo!}LU4EN*#`MqBg5SaoXM zx6SnqP|d6l`_fB{q;Q9iT!J2O#K6&*Et@l(Tu;5DJ$$X!ObjYpC`mOv-325U~Ly@lznX3n=V}_IJp!j zzD2roOC0AL^Ub*%SSaFM9zFPNtz^q*A>zK_g$>H3e!hYcFlZ8*`-skL4zOUhn5cu?&i3yt z;+vR&cQ=-o*sLM^jx4tJ?)%ca9c|A`*IA|I;Pp;wlxZKSb!?z)2zRtTAZ|@G+XWHi3jTkZnJ>Wu0hMH(24}-`L~To-J`4l1DED74?zo&(;Dvt zgdaV`->ENRYh>nlj(bi-PQ;-jQAu;&`Pcs5GWISFq*_iA;VDJFSU(V6v-)}_a?Szx zG3!&v75T4f>+}I??WREsrS-uj#D&&N>iPBQ7+7|0k)6U}9r=qIt9(n0_1M*y>G!NE z{Ol#$7bEW}Ll5=_9ke>P18GgHimirSvQ}0i9lhs0(gn6vn+77XoL)UZ@a9_6uJUv_ zq}Of-O6w*#C`7KLBar)ztjlvYA#naKr{6|1gK+lvP+Rih_hj0JZc?_OEb z?f0(TKeHOvPbcKhq=MKUeapyvZZS+frQ^M)Z+}Vig8ERJ^x5y!%)`^jN{Cg_5e*8~ zJy8`I>iIfV^ORZp==6?TbH7M?IEDg4NWS<8Gjnud8y{AF>h4!pdrE;?PpchxP;D)} zxEfA;aaCP5!5yb%8CYWd_qZ{k;L7c@? zEvKh)#&~$`*k>%2eOSjIq^c|yt)=@^h`;k(Ky5(iP~;I#5=Sfa8`>1@dg3Zxp7cUfp+nnQ--0Oc< zP3*Th@v8^hjV1)u0Zp8-?32&t8;s$-9+nqFKP80!UfwT8YQLo#n5ZgTRVaXWqy@aA zG)2Xh$2PyZg_RuA;Ccc?TP4wE)X!sWbwS$jII2tOHd--{_Y{E6>e;7CJYb!s`Uw1i|J z!Pr0h40Qs&m*QF|sN{`vpIPYUboEa`CV)0{?x+gCnXRiN(KbFx5iy&1+|zzfRmo%R zQ<*9%sVZhpT*?2qsQ&w}m`!i`Yy_Qdd~p6Z zR^!H(N2D~sQ=Q!eUj183&qZ+GSiRRQw4CmLvQvM5vdX+0;?C^dJuUy%(g$e*OK&LR z5k>iL?ekk;pRGRV5&m0C|GpPkdeZ%=%#HuiPW}Di*;#>o4&Jl>^lvS_;Ui$_;|8cq z{;hpB{{ZZ>R#dF-zqRxaD}beM^E_e}{I~Y`{|~YMyrcgg4zV;{E7HsJg7(g@Be%24 z=iwhqW-cybwF{V+ga3|Rn$Ke5css>9?{j^B{*!(Gk6}WwgjMv&__+Tni8-6DKKJC> z%r#7xO+R3ypE)`(H+Gb*ZQ++cazX!jOygM9;rO==W1M8aIOAR&*CK^+6PmrfQ3{jy4J`DwC$i%u4hqv$VINgp+zpD4M>QQ@k-mT*|$UI%HBG( z9uw+sQ#a*0MV$3>EWW(1pPSiVREE2pci@nD%Uq3aA^5l0^Jwaiz=%$PZ7PPxy8Pm| zp(t7~)QUvaVIM8Lw;@5g7l23aguON$4>pCkHuTnc`5cfBw}1YUD2MNCtk&HzZ$dAFC1YpBZ`m}6&7H<#4;Mv5_cAvFjp}!^})fP zn$ETUHlsF0vZPF1A3AW5%xhYiYh))euUjTCnlS|^_X!}!X==A+3U3)ov}WAsWH z4!d%nOyAe$6a>SpMmzdqzOnpQcPijU4LoZ5;>fy9F$|F{L)9}NZ`4lijv)vb=t z)VYODRE&@|%kgkTRAp-w$793_3r{a!jN)7za`fSxo-EH-x%ivUFHKvf>fFl(e#fd# ztr@}#)=O-VOYXlB$sK9r`Y~PCb_*t@#dpt;oR?uXIC)DSUixWk6~KPGCa#W!$asrN zw5!aUu1an8ipd$fHtJ*EiaSpuW>>u(pmL(N?<MJ5zA0OxD zL@IEGY*k;V@|KHAA|4~sr9{8yua^xz^1difcIMk2$(k^qBXE9bs?j+v-J{kCj`rxH zFAA+!9Cm|vSKM^2Q=IZ)m*48HXtK+Qu_Pfl)vdJ)r>llDY~QMmlHO;D;cG2Q?5`f> zO&Ddb7xKI=$N#SdPLL`MbEnPDZWq=@zB5&ryZ}}utNLcl)$!@$XBDU3`jmauE8m?z z@w#gkA4!O|*IDr@5&yM_q?l8-zfSyCioD0awIL*%}uc z?r!|>Vg4eXMF4vv<6dv?n0_h;=ebc8MWJw#R?=qGBC<%ygSoMPcH2CTnp>EpG*3c) zYZ`G9R?n`wYQ6v3yhJ;{xCk=y8h*s0O-JdVUK1_g5YrW1EL?{TVrdaU_{Dm!Rq< z=>9Ep)K2hh--|N7J`WyBjp})vh7Dftd)XT8?*$h?V8i{52c8 zaVlzl2~97VpU9!oiiZ(*4<|EiC8wIJ)ttdaF;yml*%$*x>t6O)LJ~w5%Tf-n>TtcJ zsnpD7j>xIPz*V*mt9p(|+ z6YW_mA=OTLQ7zQ(`&tU9B)J|QxtVy>#mG&u3#U9BkYB6HY(tM!_}=CIwduSyuD~i+ zio{T~WK6Yfk2N;lrJ7)KYTV>$q(Q&WO6(hzmt1rwU`f>W^IMVV$!TP-llD!<9H0%{ z6;Moqy{9Z5nY%XzWAa0f3|)>$Gcz)Zyo~(M&zG*4kc=-1Bk@WTUuC`#IoXVS5Ceg;pN75${#mo>&>o;mZ>!zFXXPXR}*-hj6O$M`5<{_ z&l+P*&buy5rrmtyL#GFpg>~zDszux`(TB>}i=ov8!C&EN@#Ux+YT2&;RaM^8JJr}s! zJ*DA3wk4HpxJ#!HkXR)3;=rs^g!WeR&V>mBUaKK-dOf^9gDLB2e0Cs?7F zy_|Y1XRD8^jNo5&-)Gk@Ov4lT8UtR&+oh>zYZ6kO5!0bmIA|{Rd^xV2^e8ubJ#)1* z#JNf-_VTCz82&h8K460%_s-Ikq#WlRpvQ)6o|)-#X-&tAy-cL4hXkH)7H7jkBl}(q zuR;t8br~SNFZ%g9Z8X%<7V=Gxo|Bmj|Mx{Fnpa(i*VkR9FJls(n>ryZ^ijvE?$jgEa#lxr+$U+<4KvqZD_TfyTUf#=qIb)sYq-wJ2UoO=GH*d!?!kps>2`335 z^eXYaDA@^<+*$2oGtO?rDwXbZ8!V%v)#y}V@k*xV zjT?f(P(5rC)0kGQjn~D=1apG#n)cUj0SSXA6euLR)Gr;2r0}I2BFgH7S-`9PwbY#G{{V#l1K1!tl4v=j38$$|+qD z1NW2OXwQ_xQ*)xq@3gnKh~6F~e8S#1h?^QH9OU~DicEYx7Y$j@G}(iwk3(Mf!)(tU zxpSJIdMf?d^sm6*I#$d}v=tFqjR5~wC+;tv_{JG;9<1d5$~|)NdpMR74wSQMjg8dj zsDix>qk>GNNAIT;w!Zs8nK`Cd2aYC2zrqOxL|hE?fNqoN$xmO#8zr)=Kk^q3dp||R z&b$ynH|DqB#XcH^6c}fsvWM%(^WC+uktKE zy~ovAY&Ja5v?>`Ys~xSvU!mLS^u80UDg{ybb$psB;kkmZGSHc`ssQp1I6#l(A{Z2_1*>!ir>M>N>wsQ>H$h?%Cp7 z$&$N2zjvyQ{9o+7c{rQ<*8ks8M>=S0m!fuymZCM*6jg0eQ(H<6p=zFMCK0MyI+<$> zQbkmWnCC=WRkI)ng0wY6#GFJL5x%$Xv!7?5=bXKt=bY>J&+od$CDnmfszmCt9b z_jZ7U4~tJpEQOs z@S5$9xRZ<;Pcu*6sj^e$tSHM_&6TB8$@|SX2e*QHyZ1SMNwvLB zJrZZP8}N0qw9-1|2os-_sd*9m>}MS#g$Ax*N|Ax51WE=_PGXlkm=HI^;XAumf3#G) zLuSGw>hm6-@=$~t^vrhgX^23pgyV?T*na64NNlGQ%rxEB)h5KRG9!K9Wfqsw45|E3 za>r)KLhx|lY4z5n8OpLy)`>LVS!DKv-1eZl5F>C8YOsx4zCZgxWmLnN%&QAeB|9fdkiq= zuzA;OPr2C*nn?rlC3BW3 z8^)&CDg!KJ(Q@(!p6i-;j+rj`UL3g32?ioY9F7Rrs=Gd)wP52ho`|*0GQV5T(=KBT zF*DiRX?Um&>+!%Z?$Jq7;8fh2b7{zU65Q7iHlieDHiLP?UdxRO*%N*9-8VgOSGJ~D>HR*rJ*`qW+FctK1|{gWeD~t z`%6*yaP#_J9$ulOd$k*bX?@M~l~hR&VhwuydoF?a=tJ2+M~|ImUl~Ot%3=4=1~p5z z_iI(CIVKAwLrjy<#8PQ^D!IMLEjA?(=32Jz(JKT3$Wuf{EGHHXmQcuWq?w_TOI$I0 z)H*}{a-I+Pu50CB0o<5Ww%dTDwBViC9W{EOMRk7Ol=oO5u8U@mra0W%OztLLFzz;F z-XjF+5KNY49%+JezTT1|G;UhN180TBibY579v+5Fvol=HLyB+PMD%QHi=*ZEXQS>s zEt&KcJO;7M+W}(i3*NAAY#d`YU0+%Iz<}y>l?r2BOQ8`Y3v?!n3(}?)uv%3xkJ{ zo93(E9hLiaqKxbe=L7_`paQ|u;eN5cy`N6W;Xo$RDe$Z=cYfu(*csAYC4;1>Fjk!! zokn<@RXC@pBB%lqQmgT~jc+QgV^`DHXI#>RrmM^r5>fjiy%|#Y*7GO5#saVJh=_xQ zv@1vy>X$ECq&o*9+??K=lu%U7+|2eKe)$$x6$N+02A4I=pP>jXyiHPe^a1PXqN{t# zrq12?eqFZ%x#v{YaX&U4BYB5qf9Oa)rrxDJ5GH?rHk(L_6E{FVk&`)jJ_Y7jM!(d= z8j+`c|8_eLjNZO{`X#qyUwC-ke4Oo=9UBGlsPFGru+}Pjtk~XFm#l+jUM(naGU8Qp z$7irjTZ=IYt2rmHu3eG0@rWc9D=@rw_W4c8g;^i>z$-{Tt0?kpoee=4OWo>olmC4B zeF^#1BC9kiA60gA1JF3ag=%|<5>h!vu&Y@b!;4x{jonArzif%!6f1xGx{K;N*3(Wmhm0=sa&Nb_?Ij^7R%;7)7tO$O(AzB}hh=_R(p~mJfzv@s>3PoslfCopyT=kz`nKaZ`mq6? z5@8Cw?rNXu3p2?5z8E*xXy0@?kf}VR&d4o7L+L{mcR`;R)dmp3nXKj>Q&E)B46TJb zXv7h+Iv_IT!LO0in3>ch_BDW=9CWBi7r|h;9=ggsdZpanm&R<%zeS9TbCaiUlz!12ONYmH#s0O5cy5QaW`KHK#Y@Ju z%2D5YpL_9gnu7aF9@uieo;i>>R@mB?lED4(U3gIJXzjHuY_% zlt+4$+;A(Y_%B%*${GUtMo$*_mbj<*%8ayH+AABXrf*f$Xg9{Z-5xctN3P>-QunLH zO8j8yK+`B>%&NdDO-Q0!+1<5j`o70|X0r#i7U`>j^LY5#7H z`5x}R(b`~}WA^zSZnMRuI|r7tGS+w&B;ZPBdSsrKHezu!)4ir=7iVO0RhBY1P^@vs z7+@+qEO~Xwxzg&g+q-#xjrLWG9-9|Qw z^6IRAp~3HH-K=-e`fOJg7#Y)s|0KaVi>+r z81Tpnpv0U7(q~Lqk@ubI>1jKf-HZS3a{S|8$6j0!^O4ki&J|5P?~zf%t!j1uG1wS> zQ2yfs&k=lUTz*qInP<6zdM`#q=Ir;&Bu@r z3u1?F5DYr^TO91}oO{=l$jzU0X$V_Nx&IN^B|2(6%^h32c``@Z;OJ9{`+xLK96UJ= z20e&$7hK$>GwVZg%P;#?=$2A9ea5WRul|A@A!EbCyk1)j0hU-?pS)&nZkCm8j)7oS z>{VuNMjg+*s}Pz z$LTs;w#lNUEl8Y)BY1+w;RD8#=aKW2zEYHxZRFPg?g01q$z7I(#x1P`W&Z;iKNlU^ zavD=z1L8g+A^dHPC#`8-alW6Yk$cb_^3R;f&K3%jf}0%wmR z`GmlBPR7OvCm41FNL%~*r2D#PX~+A-zAWtk)p(#5#(V00bYS!#>|%R1xxX5QFB^VM zw=N+@0T7XKtA|dj0>95@&2MYav~nl?@kS+d{6b)7#ZIg!1 zs0W_=cvf_Aq+H9rd{HeRyhuqsazIFjslQqt%Rv+s9`N&y`p}o@vs68lSiZ`dUC_-I69tgi-KpOd#j1Bbb?jm_YUiWDjATCdd4A)>K$N``O z_31Lecxo_QEbI2K)wud=Rp}yA@+f)Fzz|p#I5RzRj#u@{<3nBzHRgtDVLBC43M7r| ztw44z!BS^3ErBbuwj?xPx`pG zER}rElL^*2B3zyz5N)e;H?W*KV--fsC1?U23r|$Sp!g&!k+Xqhg-&6W>WA~E6&|fp z*k+%T*=khUfY6hzp^d^TDnA#xSKoGr=&Ti=x`T)doa*tNC1Pt$bQgCAZv9+Y$9x2i z`fR1W#a>&hTu$(;>S%x6c#aH*ZXZl39Vi=ZKqUU97C3mgbqr;>Q}pEg!R-a`nWc9i zmptCRd2E+kgp0iIR+hEc)p7r4E5gx*NCUK(HfSn9K(F!7)% zVD|7kBaUrwchd!ha0|~qYdj#yi;O^RRCzDsZ;jM}gsvz!LP(K{3=7cOXJoAJo4OlS zlY76ew7>JTn`fcR)W&n*T&2e2xz?ZtImI8v? z<}Bg+$JOcnUuO5hY}J|~0K#Rkja#g?p< z!TyIK0jjFMZI;tVR17StzKPRg9BiTR&W-P3dH5zUc6LHuv|t?W=iCoSlk@Ql8^TgA zW|vmV1+GWwx?kY1lCQrd4$IqL9sMAU%korWy7=yx0~QD7<7p-t(U1;)o!a!hHpz$0 zXZ@_Wcehbd{7$y(?0h2TO{Wz>af+HTvJRv$TS?MitygnQB_$ixu%TB>jvHQRuFZAl zmN*DxM+a~0QY*hYa*cXY#o+H_!TQf*A^9L~-C0Y6DSULZs6*$Ul|jGXcLlqPKSrqI z_G3UX)zb2mQ_t}5u&*JUs_)n;0(OecXfeAC2cSIdYYJ(nWt7lmyVD-U%yH(N=1W~Z z^15A*FfTLi3bzPeSJImzrf|wX-p7z&3{e(&F{Q&0ua(Bbnn5b)%^qU95J>E?-78Mw zxAI4J6$(D@&oRmuJ5>JQn_|Hwn-{_b^oc_nQZKW{Pe-}-sUN6ok%B7YeUs|d?1ck2 zk<(0|mFU$#11idIFCg&QTF7$mK$*1UUL13zcPA;ZL(+#}K+AO{heKHJT7Km^n=R2$Q=W8o zx0vAjr9tZS3ogcp$%g9&uAs{@Ul)CQY>;q;xldPw991Q8O{LtZkNPH#_+H;5!G<7$ z6J}cAod{r?O!2jG2mC64PDC&@L^rcAgVHYyU|VYPM8v@zS?1Xu4-VpJT77{If-3}M zF79g&pu70aTKnpbPk4ZVa2yW+GHRoABavzmK>umwOst9dyLp1r$)k<0wauxF`XJBg zD4yQ^j(@p=sO_tW56#n-zS)t}Gf%9FF9v7yx*D7hxNP$Sa_ap!QstB>LX(nxt+0zv zmMFn&$JU9sP(3p2G*#u?oyGBqX$HfXx}m86$QNQ%o6|y&@$7_$lA4SW=`KJ)0UstW7ic1{B^rQ0#> zt6%Q{eC-~ZVZL4cr-~HR?^Gp8AqiJGSL+J;@m8BVZ}=s4cx6c7Nh+5|*6YpG&L5bK zG>tw{^$BPm^|aBym_IlbBR9Wb%X_`Sf6RI~bHFmml3P90ORk|9S&5A57E|{>!(ZrS0u(c)<~%{ zXlR$c10?GFXIW#hU59nVF1p(XvX2{j7n4g|MBq7R*$|t5Ma9acG5mh)Nh1KzTsKeT{g>Y1 ze_rd4r|UoG@Q=Uz|HQ_BV&i|VGJjjB{(rGXy#a5O{VKjI=G6z5S#eml!%anD05zJi%58k+SQn}J-f2j5p zD0q(9@nY@lDG$c^8v=b11cKmQJI6eSf2T!klz2w!$8sI>|vT_m!^d9O#KW<gifK zJ~8LJ%*7L^n8dQ%=HgNnc^qGZexP;?gfR2Y>{eJv_~W!cxLF1{lSz)bh|_LsdA_ z1!zSr?pEC|cfpnw(^jN0Y&=Zr1?(IuWTd{$yeUpk>W zUr|`Tp}t~qNO8n}N|N}N^&rE=5LPi!(2JMd9yNu|i?+bqwmz|bUWN{;^zX2!b*)}+ z#5EpH^4}ntY10-uJrJRW5coK2BvqPE14=gUeeXb1)YHnoD#E_D$MMGg<+`f;=6v_A z+G>wbDFseXwNWwO{lJG|s8dc7Lsb|aF3B>>ow}0D8`p!FPRPlFB`*~(%9(}1>Kc|} z2YgDTgInma7>R&S}?ANe!RHb}a{1Z>H!dFnoITy@p6a87x(Aa<<9DV?)W1~fMp zlL5k;&$B@B5`}F!ktaD`DFu_ccM=iuBAdIEp^}c&{%-ADz?LW0rqY&c|1YNx$|zv0 zG!-IV#4vj;4Wl8deNVS|g#zW&eRd=F2yUsXucyVhPtV{BBVMJC02t|rxPzDcsfK~U zb(@uMVl`+|oM+!4z1_qdYq>>=Dnm?X`wCEgb8$S=g(&4?yY-#D48e63M#sM`eQPSS zV`^4kE?`-Y5#igCL;F6SvTLIymD%AmDZ`bRQ%;a?_zGf3alrxkWmGH=VQB22|9jo2 z=kq2?XoM1~AYoixWyW2?gXI6AdphHN6!OeT`pUXZd!EnKLccef^Rb{b%Qkd%x%Q5w zQ)X^TYQJXLM(+|L%iq7KxVSBN!H;kLZVvns!LP?k8@f91%q_ozte(zOMD%+6ay7lt z%H{dK{eAL5&ecP+Q``q-3I(15Vr$TcJ?bf1H~dn@9rxn8!Nj%zUm15Az5fs%vQ=KtP+KS(P~8ow{cz90$_E&N9Dm$^GRIdWNzB zDk-}q197QR{)SrU+m>Gs@2p2J_LJ9pOj(pj>pegV>FHq0UA>j{-uWn_?)Cec8ffbV z@ESb8BR5#u^@I1*;Ofp$Bj<&3Y~ILDsYZALzu&^c48Pgep_!zx4-I^G31Xp*Jb%P` znbp(%+9UDLdsfpnPxR387v-6Jgv%Y z2wiUTKhm@(!#Yf{cvKXpjuo>YcfV2-5wZ_#PRVb7rkm!Va*Y%QX4x0*5gXf_EZ@@> zh4oE%9rJ?uCSR*)e6@h=khO2k7w@m3Vqn;4f6X;s^&qv@EG9lS_3I@#NMXX(yjoH4 z)!R+2jajr|N_Cj~V+!j6rdfpd4HGWYcvg6QP`B@6NN*0MN+2smT=*VvJv$Wcv+Ak^JT(zcyr-XGG-(-@ake=5*xeuLT#1pP|C;_t{@k- zi}!6;T;$AFoNG1Q$xq%u{Yo$C4aMX5&)RI@YYT9j%*MjWK_hiOl&_N`7~0UVDIktK zf!T1+3*oGUwf_yDZXUa!pt1S9as_ip1MF2>fB*BoWtp#LXMaInwii~y<jb+O=5Ah=9aRaqaDr?@K{#DC)IAqbo@^x}?Y?c2L<%zx{w_ys~$k~>B+RWJERvs~TiZ4GT+SMG!?T6@@goZj7uIFl@Bd&TB zUZqa3*Kbc>&xb^m}v*Y&{s0UqMJxFSBG@2c^_9zZi zVr2-|BXBf&L*bWa8^5$63nMv~W_Q;h_U%}2Ue|lel%7{-OY&*NkH}A2@5LCw(A1ug zBF#P*9S}gF&0xO5jrvr|1^-xt{ZS|R&@28X6SPZwUg`sPsHEH3M=lj|XAzDwTtgHx z&{b@IRVQOiFGseCGg7t?;R~^JeqF`~dNZ^;>Gs6LM=d*}Stq4zthx_}Y~<8l6zjBO&aZr8L{MB9<8b5H{rrvqlLfr41Z&Xg^Jo1l+3Gj6@BmFs zwo(XssKhFvz-~r3)}?2)jdN;weLM*nPq%=EV5R=@L@%LveIrRl7o%|QJUNoOtOcW8 zPT&r$Nub0RI`<-=J>5G>x$kbFjThuxZ5*tu%wuH&K(Iv^qTql>nG5g2O@2eLD+qf8 z?&lM&SW_;t98}si)q0ys%6t>@4_vk%481*bQc`TjyWK@JfY$H#H!geNKnXsH{}E*5 zdXs^di?}xXi8VpQ;ias>WnK$K64df#V(gk9HFIAUUQLEQH)$eJKvSeVM8gfx>WZ1r zU?f=;ygDS9wyl%VF%jONn0l{TApZC<48$AHj9%bN2wB`sB0ml|=R4trdk#}ZPtAE^ z4>qpBDUYlzJp-Q&hGgJ--1-S024(DLe@1Z9A0wD@N!)RqMK1?F970b!Amvc15Xe^) z==y?y9l`EdI9D^!m3sm_+EGqIYZ0%(qW}bw(^Uhu%ep3*IJZ$9cB@87+CME3E8X~F5PJ@+Oh|)7Vt?!68h6T{-|F5om3lR z7_L8Qe1$V=yXwbGk|Fba?%6B_ePJE#arD3F_Rff#zsx1|ze7-1HV(0GVIT1c~2`hgJ0KGZ4GJ|aMXEV;6PJq89W5Q=y6EvLd!wZ=c^C$3qBo0 zAVm?7x2-UJ)oD_@G~m^Xt-E>UXW@F-26CSFCrR&>rFSVYd}JO!E3pV-KH2 zd>mD!!d-DQ7av#lbd<cg?ORl;e~4S!U!Jc8K)I}GV~$X)aX(GV%iY4_%x3E$%XG=QURIg)$zN~(vGA- z89kCP&<5V3YBh`N#LTAwEGo6)uCDzA@yYL4?R5aFRXO&c_zI(@xK)>Y=m?C|YWN!s zRS|;aQu#bJQ;>FA)l%D|dTbI@tu=Cuc@JnOFM5eEdRMG`nkL`no1xemi_(cuEap~L zja!-tT&PCj?&Xi)e*z1@YP!WBi~r=h>6|>=2WiHxGBmZ#OCo6lhHo0De!-H(Cxd4< z9_i8M9CxfLP%gIcg--tHZ`de@O;`s{hskLM06<_XjNb z$MnQ)f5ktAb1%2=YKVI2Pt^zk*xdY4){c%b&o`8CR3dFR*_!uZK>10K6K-{<53wWV z2D}cVZ)FRa?HeC)hpZfuoGeCT&St7qCvAoIKi5ADEF^ws|cX*^va~lU%t8XSlIIOQLSRd;5RoETNhkDa(O6}y=1=UAgdKP zcji(KNI!r4p%}6DDC0Zbz9z^s&;6pNclt)~=8JxkD8cA8GtCy}(SAJ0i9o6N4L)}t zDoc?ikBxO_9vW^LYb;=0a{Q&cu(NU=sft~CtL8ELPAfD(NsE{I5S27V$$m8-a3(W5 z$$xm`JwI!T8`tK$sdETt1x)ggUZ45VU-FROl=*E_MPcoedG%%&pUvw=Qn{7WR1QhS zT)HM8#5MHo$rsfyqGInYGvAF{VzkaneBI_Ug8Gl#l;r5|R*RGd=`nUYtLhk?yCi>D z6S~e@zW*S!`6Joj&^*+OTn>`#iW`vu)d9tO*B$4jCmZT+kMC@feQ_Pwol$oRZy3cg zmnvE_jFq?r5iY#`tgO1P)~G~c@$9YA5v}ofLlGcCe7^yRdGv9PI6*Cue{g+FZ0IiZ zNri62-RiVA10h*>0NDHdVX)VU-zSv*1Hc2@DIQwI5kOgabvEZH_9v**T*BbY`~zN2 zg(mqW3mTsG{+$)x;>#DC>KeIb!gI{V@B7ip7(0*a10@lfWtQP z``!`jdB|Evt!UUlL?q@pB&fNef_gSePHT^wYqv7Pg(Q#efod`tx*}U3h3sW&BbmO2 zp?nH!UAtR9`LaN1agR?rLGy9`=!O28Ym=Q5wWQC4x?=@95fJ1~`y;Kjfa} z-<2%M4gY2HSGo=J^>eOQ^g=eHam)z@s2h?kxYiKmx`AC9qrNn?VYeL}bT3Lnm+aIW zo$mrH$}jrylNNvpEMAq3yEOK$r@(WnZl44DAxAKl=U!|cgV?5Bs z&uYFpYj>V;Qvk;|!ily|rQ$d#V|Qc$?HgVu|O+ z!@lEVw(xfSD|W``?GeZrN^w}Nv*pX*X93`$H|m6^u#1hq#%_uTt|KJ_;4()S*QeKq z6xVD`OpW3KVIJ1N%TOgHneAYb`MdHv79-ciGEWL~1B&kt^S1kQy& zj`bRy5X(GN%NK$;uv1^aD?+Q%YIDQwy2J-VGFbzwc*5R^nzrcs-eI}+28{da&a+zG zv4c;mZXW-Xfb+jKZAZ%!j~7rn5I(y5V#x|>`C#;Tg_^tRfm zMZWgYJ?&FZwI%N?DjBU8bb3m3Dd&UcEnjD+0m95ECxi^Zl9P&ORqrhrkzm(N)2=8Q z9N4S`ov zI?PMukp%Rf@cboR;{r<3mBxw^mu{Hk+izfh^Q6RBo}G6sl}wPTpR+xyu?q*%=EjHH zZS=@;_?BrnLq_VpYT4OE{o?S=>~QnG1V|TZS=h!?(>H(qV)?F=j9KmtTb875V8-Lam&s zOi#IB(EWgcl!0%ie zC$frTx%Acj*a9oLl%$8#qGzVcaQZRa1&Y6O{Ar3N)GFKSV$XtGOBPCjOQ0&2A{Qe0 z6OLEd67|%-#OrVE+rA$;JHQ{|a8oTjcnn^7;foyso!-jrvWmX-K-w7%*TKquQr@@CQl^&UD2-qAvnlbbC`{2v}&1<#t;J{WJRDQZEf* z3%dg~NWq9L(~oz=6c0ScZ<9kaYzBVUDJb;#_DQjfoj5(z#tDc^uQb@Wm$Uqs> zpD~ejyL4lvoNz~D|H~8oU4S}!bpgoiAZx)|53cL4#2h1ve2a=(o5==JCd;!3U?SXK z`aw>q?pT|ce`5kn1>L=DoiG{^la+q5>ekLDrs&Zm3-w>!_UaR9qf5GJS7gQ>&iBz* zRnUTc(W;`p4Wcp*k5QvCW{O2a$9GTHel&-?Vs!M`D6R_jUk7}vFmo})c_)X+U;DUC zG3i1OQ!-%XWDm)&Kl#D;wyHY-=4Pmx_DwTNdAnkjQ~nt+^1M^h%`iOt1$=x3d#SgD zym9A9rfU7enbw8A0;pqZpD0qP3E4N4C`)+mS-%?X_t3$P4R|GomdgkX;SmLk+$Q6N zi<)l@@~j=-O(h%|%jr7NX)Vrn;}^mjLp}A=<==QmQI0Rs4@3bqo1~J~g1Sp&EZ7N> z>1SMQ3rXB6+k9y}X+{=`>Q#%FF@8p`&w?yQ)Ai6Zo6AhEGDnx`^%&_p1wSj}W_nUz>4KUD(;GTve^Np2~ROf#j#@FY-B{~_! z1q$6;BK_wNfZwcPk=1L5H)ZW5kQ6EYZ=ff%ECF;E7zfDA^nz2O2LyQn)?aNLS+eE5 z(g4$I*fW@mTMMb≶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 literal 0 HcmV?d00001 diff --git a/squadai/squadai_tools/tools/nl2sql/images/image-5.png b/squadai/squadai_tools/tools/nl2sql/images/image-5.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d6013dabb306a84987746604fced2606cc3bc3 GIT binary patch literal 66131 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


;CPR)pd0t8Q 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%0A+={fK+X-{t*uH7HS+r{TkP}eZgJ*`p@AFKz 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 afn;v};yKrKlgAFF?^jmPkS~`r_4^M?gl*~o literal 0 HcmV?d00001 diff --git a/squadai/squadai_tools/tools/nl2sql/images/image-9.png b/squadai/squadai_tools/tools/nl2sql/images/image-9.png new file mode 100644 index 0000000000000000000000000000000000000000..87f3824342c3aa28496ac5a44f06fad67f0d36fb GIT binary patch literal 56650 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 literal 0 HcmV?d00001 diff --git a/squadai/squadai_tools/tools/nl2sql/nl2sql_tool.py b/squadai/squadai_tools/tools/nl2sql/nl2sql_tool.py new file mode 100644 index 0000000..22c3a29 --- /dev/null +++ b/squadai/squadai_tools/tools/nl2sql/nl2sql_tool.py @@ -0,0 +1,80 @@ +from typing import Any, Union + +from ..base_tool import BaseTool +from pydantic import BaseModel, Field +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from typing import Type, Any + +class NL2SQLToolInput(BaseModel): + sql_query: str = Field( + title="SQL Query", + description="The SQL query to execute.", + ) + +class NL2SQLTool(BaseTool): + name: str = "NL2SQLTool" + description: str = "Converts natural language to SQL queries and executes them." + db_uri: str = Field( + title="Database URI", + description="The URI of the database to connect to.", + ) + tables: list = [] + columns: dict = {} + args_schema: Type[BaseModel] = NL2SQLToolInput + + def model_post_init(self, __context: Any) -> None: + data = {} + tables = self._fetch_available_tables() + + for table in tables: + table_columns = self._fetch_all_available_columns(table["table_name"]) + data[f'{table["table_name"]}_columns'] = table_columns + + self.tables = tables + self.columns = data + + def _fetch_available_tables(self): + return self.execute_sql( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';" + ) + + def _fetch_all_available_columns(self, table_name: str): + return self.execute_sql( + f"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '{table_name}';" + ) + + def _run(self, sql_query: str): + try: + data = self.execute_sql(sql_query) + except Exception as exc: + data = ( + f"Based on these tables {self.tables} and columns {self.columns}, " + "you can create SQL queries to retrieve data from the database." + f"Get the original request {sql_query} and the error {exc} and create the correct SQL query." + ) + + return data + + def execute_sql(self, sql_query: str) -> Union[list, str]: + engine = create_engine(self.db_uri) + Session = sessionmaker(bind=engine) + session = Session() + try: + result = session.execute(text(sql_query)) + session.commit() + + if result.returns_rows: + columns = result.keys() + data = [dict(zip(columns, row)) for row in result.fetchall()] + return data + else: + return f"Query {sql_query} executed successfully" + + except Exception as e: + session.rollback() + raise e + + finally: + session.close() diff --git a/squadai/squadai_tools/tools/pdf_search_tool/README.md b/squadai/squadai_tools/tools/pdf_search_tool/README.md new file mode 100644 index 0000000..dc7db55 --- /dev/null +++ b/squadai/squadai_tools/tools/pdf_search_tool/README.md @@ -0,0 +1,57 @@ +# PDFSearchTool + +## Description +The PDFSearchTool is a RAG tool designed for semantic searches within PDF content. It allows for inputting a search query and a PDF document, leveraging advanced search techniques to find relevant content efficiently. This capability makes it especially useful for extracting specific information from large PDF files quickly. + +## Installation +To get started with the PDFSearchTool, first, ensure the squadai_tools package is installed with the following command: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Here's how to use the PDFSearchTool to search within a PDF document: + +```python +from squadai_tools import PDFSearchTool + +# Initialize the tool allowing for any PDF content search if the path is provided during execution +tool = PDFSearchTool() + +# OR + +# Initialize the tool with a specific PDF path for exclusive search within that document +tool = PDFSearchTool(pdf='path/to/your/document.pdf') +``` + +## Arguments +- `pdf`: **Optinal** The PDF path for the search. Can be provided at initialization or within the `run` method's arguments. If provided at initialization, the tool confines its search to the specified document. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = PDFSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/pdf_search_tool/pdf_search_tool.py b/squadai/squadai_tools/tools/pdf_search_tool/pdf_search_tool.py new file mode 100644 index 0000000..49c18ce --- /dev/null +++ b/squadai/squadai_tools/tools/pdf_search_tool/pdf_search_tool.py @@ -0,0 +1,69 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic import model_validator +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedPDFSearchToolSchema(BaseModel): + """Input for PDFSearchTool.""" + + query: str = Field( + ..., description="Mandatory query you want to use to search the PDF's content" + ) + + +class PDFSearchToolSchema(FixedPDFSearchToolSchema): + """Input for PDFSearchTool.""" + + pdf: str = Field(..., description="Mandatory pdf path you want to search") + + +class PDFSearchTool(RagTool): + name: str = "Search a PDF's content" + description: str = ( + "A tool that can be used to semantic search a query from a PDF's content." + ) + args_schema: Type[BaseModel] = PDFSearchToolSchema + + def __init__(self, pdf: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if pdf is not None: + self.add(pdf) + self.description = f"A tool that can be used to semantic search a query the {pdf} PDF's content." + self.args_schema = FixedPDFSearchToolSchema + self._generate_description() + + @model_validator(mode="after") + def _set_default_adapter(self): + if isinstance(self.adapter, RagTool._AdapterPlaceholder): + from embedchain import App + + from squadai_tools.adapters.pdf_embedchain_adapter import ( + PDFEmbedchainAdapter, + ) + + app = App.from_config(config=self.config) if self.config else App() + self.adapter = PDFEmbedchainAdapter( + embedchain_app=app, summarize=self.summarize + ) + + return self + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.PDF_FILE + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "pdf" in kwargs: + self.add(kwargs["pdf"]) diff --git a/squadai/squadai_tools/tools/pdf_text_writing_tool/pdf_text_writing_tool.py b/squadai/squadai_tools/tools/pdf_text_writing_tool/pdf_text_writing_tool.py new file mode 100644 index 0000000..c3a686b --- /dev/null +++ b/squadai/squadai_tools/tools/pdf_text_writing_tool/pdf_text_writing_tool.py @@ -0,0 +1,66 @@ +from typing import Any, Optional, Type +from pydantic import BaseModel, Field +from pypdf import PdfReader, PdfWriter, PageObject, ContentStream, NameObject, Font +from pathlib import Path + + +class PDFTextWritingToolSchema(BaseModel): + """Input schema for PDFTextWritingTool.""" + pdf_path: str = Field(..., description="Path to the PDF file to modify") + text: str = Field(..., description="Text to add to the PDF") + position: tuple = Field(..., description="Tuple of (x, y) coordinates for text placement") + font_size: int = Field(default=12, description="Font size of the text") + font_color: str = Field(default="0 0 0 rg", description="RGB color code for the text") + font_name: Optional[str] = Field(default="F1", description="Font name for standard fonts") + font_file: Optional[str] = Field(None, description="Path to a .ttf font file for custom font usage") + page_number: int = Field(default=0, description="Page number to add text to") + + +class PDFTextWritingTool(RagTool): + """A tool to add text to specific positions in a PDF, with custom font support.""" + name: str = "PDF Text Writing Tool" + description: str = "A tool that can write text to a specific position in a PDF document, with optional custom font embedding." + args_schema: Type[BaseModel] = PDFTextWritingToolSchema + + def run(self, pdf_path: str, text: str, position: tuple, font_size: int, font_color: str, + font_name: str = "F1", font_file: Optional[str] = None, page_number: int = 0, **kwargs) -> str: + reader = PdfReader(pdf_path) + writer = PdfWriter() + + if page_number >= len(reader.pages): + return "Page number out of range." + + page: PageObject = reader.pages[page_number] + content = ContentStream(page["/Contents"].data, reader) + + if font_file: + # Check if the font file exists + if not Path(font_file).exists(): + return "Font file does not exist." + + # Embed the custom font + font_name = self.embed_font(writer, font_file) + + # Prepare text operation with the custom or standard font + x_position, y_position = position + text_operation = f"BT /{font_name} {font_size} Tf {x_position} {y_position} Td ({text}) Tj ET" + content.operations.append([font_color]) # Set color + content.operations.append([text_operation]) # Add text + + # Replace old content with new content + page[NameObject("/Contents")] = content + writer.add_page(page) + + # Save the new PDF + output_pdf_path = "modified_output.pdf" + with open(output_pdf_path, "wb") as out_file: + writer.write(out_file) + + return f"Text added to {output_pdf_path} successfully." + + def embed_font(self, writer: PdfWriter, font_file: str) -> str: + """Embeds a TTF font into the PDF and returns the font name.""" + with open(font_file, "rb") as file: + font = Font.true_type(file.read()) + font_ref = writer.add_object(font) + return font_ref \ No newline at end of file diff --git a/squadai/squadai_tools/tools/pg_seach_tool/README.md b/squadai/squadai_tools/tools/pg_seach_tool/README.md new file mode 100644 index 0000000..2a505db --- /dev/null +++ b/squadai/squadai_tools/tools/pg_seach_tool/README.md @@ -0,0 +1,56 @@ +# PGSearchTool + +## Description +This tool is designed to facilitate semantic searches within PostgreSQL database tables. Leveraging the RAG (Retrieve and Generate) technology, the PGSearchTool provides users with an efficient means of querying database table content, specifically tailored for PostgreSQL databases. It simplifies the process of finding relevant data through semantic search queries, making it an invaluable resource for users needing to perform advanced queries on extensive datasets within a PostgreSQL database. + +## Installation +To install the `squadai_tools` package and utilize the PGSearchTool, execute the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Below is an example showcasing how to use the PGSearchTool to conduct a semantic search on a table within a PostgreSQL database: + +```python +from squadai_tools import PGSearchTool + +# Initialize the tool with the database URI and the target table name +tool = PGSearchTool(db_uri='postgresql://user:password@localhost:5432/mydatabase', table_name='employees') + +``` + +## Arguments +The PGSearchTool requires the following arguments for its operation: + +- `db_uri`: A string representing the URI of the PostgreSQL database to be queried. This argument is mandatory and must include the necessary authentication details and the location of the database. +- `table_name`: A string specifying the name of the table within the database on which the semantic search will be performed. This argument is mandatory. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = PGSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/pg_seach_tool/pg_search_tool.py b/squadai/squadai_tools/tools/pg_seach_tool/pg_search_tool.py new file mode 100644 index 0000000..6f9ea29 --- /dev/null +++ b/squadai/squadai_tools/tools/pg_seach_tool/pg_search_tool.py @@ -0,0 +1,44 @@ +from typing import Any, Type + +from embedchain.loaders.postgres import PostgresLoader +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class PGSearchToolSchema(BaseModel): + """Input for PGSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory semantic search query you want to use to search the database's content", + ) + + +class PGSearchTool(RagTool): + name: str = "Search a database's table content" + description: str = "A tool that can be used to semantic search a query from a database table's content." + args_schema: Type[BaseModel] = PGSearchToolSchema + db_uri: str = Field(..., description="Mandatory database URI") + + def __init__(self, table_name: str, **kwargs): + super().__init__(**kwargs) + self.add(table_name) + self.description = f"A tool that can be used to semantic search a query the {table_name} database table's content." + self._generate_description() + + def add( + self, + table_name: str, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = "postgres" + kwargs["loader"] = PostgresLoader(config=dict(url=self.db_uri)) + super().add(f"SELECT * FROM {table_name};", **kwargs) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/rag/README.md b/squadai/squadai_tools/tools/rag/README.md new file mode 100644 index 0000000..05b5d2f --- /dev/null +++ b/squadai/squadai_tools/tools/rag/README.md @@ -0,0 +1,61 @@ +# RagTool: A Dynamic Knowledge Base Tool + +RagTool is designed to answer questions by leveraging the power of RAG by leveraging (EmbedChain). It integrates seamlessly with the SquadAI ecosystem, offering a versatile and powerful solution for information retrieval. + +## **Overview** + +RagTool enables users to dynamically query a knowledge base, making it an ideal tool for applications requiring access to a vast array of information. Its flexible design allows for integration with various data sources, including files, directories, web pages, yoututbe videos and custom configurations. + +## **Usage** + +RagTool can be instantiated with data from different sources, including: + +- 📰 PDF file +- 📊 CSV file +- 📃 JSON file +- 📝 Text +- 📁 Directory/ Folder +- 🌐 HTML Web page +- 📽️ Youtube Channel +- 📺 Youtube Video +- 📚 Docs website +- 📝 MDX file +- 📄 DOCX file +- 🧾 XML file +- 📬 Gmail +- 📝 Github +- 🐘 Postgres +- 🐬 MySQL +- 🤖 Slack +- 💬 Discord +- 🗨️ Discourse +- 📝 Substack +- 🐝 Beehiiv +- 💾 Dropbox +- 🖼️ Image +- ⚙️ Custom + +#### **Creating an Instance** + +```python +from squadai_tools.tools.rag_tool import RagTool + +# Example: Loading from a file +rag_tool = RagTool().from_file('path/to/your/file.txt') + +# Example: Loading from a directory +rag_tool = RagTool().from_directory('path/to/your/directory') + +# Example: Loading from a web page +rag_tool = RagTool().from_web_page('https://example.com') +``` + +## **Contribution** + +Contributions to RagTool and the broader SquadAI tools ecosystem are welcome. To contribute, please follow the standard GitHub workflow for forking the repository, making changes, and submitting a pull request. + +## **License** + +RagTool is open-source and available under the MIT license. + +Thank you for considering RagTool for your knowledge base needs. Your contributions and feedback are invaluable to making RagTool even better. diff --git a/squadai/squadai_tools/tools/rag/__init__.py b/squadai/squadai_tools/tools/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/squadai_tools/tools/rag/rag_tool.py b/squadai/squadai_tools/tools/rag/rag_tool.py new file mode 100644 index 0000000..313db12 --- /dev/null +++ b/squadai/squadai_tools/tools/rag/rag_tool.py @@ -0,0 +1,71 @@ +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import BaseModel, Field, model_validator + +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class Adapter(BaseModel, ABC): + class Config: + arbitrary_types_allowed = True + + @abstractmethod + def query(self, question: str) -> str: + """Query the knowledge base with a question and return the answer.""" + + @abstractmethod + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + """Add content to the knowledge base.""" + + +class RagTool(BaseTool): + class _AdapterPlaceholder(Adapter): + def query(self, question: str) -> str: + raise NotImplementedError + + def add(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError + + name: str = "Knowledge base" + description: str = "A knowledge base that can be used to answer questions." + summarize: bool = False + adapter: Adapter = Field(default_factory=_AdapterPlaceholder) + config: dict[str, Any] | None = None + + @model_validator(mode="after") + def _set_default_adapter(self): + if isinstance(self.adapter, RagTool._AdapterPlaceholder): + from embedchain import App + + from squadai_tools.adapters.embedchain_adapter import EmbedchainAdapter + + app = App.from_config(config=self.config) if self.config else App() + self.adapter = EmbedchainAdapter( + embedchain_app=app, summarize=self.summarize + ) + + return self + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + self.adapter.add(*args, **kwargs) + + def _run( + self, + query: str, + **kwargs: Any, + ) -> Any: + self._before_run(query, **kwargs) + + return f"Relevant Content:\n{self.adapter.query(query)}" + + def _before_run(self, query, **kwargs): + pass diff --git a/squadai/squadai_tools/tools/scrape_element_from_website/scrape_element_from_website.py b/squadai/squadai_tools/tools/scrape_element_from_website/scrape_element_from_website.py new file mode 100644 index 0000000..36bc088 --- /dev/null +++ b/squadai/squadai_tools/tools/scrape_element_from_website/scrape_element_from_website.py @@ -0,0 +1,57 @@ +import os +import requests +from bs4 import BeautifulSoup +from typing import Optional, Type, Any +from pydantic.v1 import BaseModel, Field +from ..base_tool import BaseTool + +class FixedScrapeElementFromWebsiteToolSchema(BaseModel): + """Input for ScrapeElementFromWebsiteTool.""" + pass + +class ScrapeElementFromWebsiteToolSchema(FixedScrapeElementFromWebsiteToolSchema): + """Input for ScrapeElementFromWebsiteTool.""" + website_url: str = Field(..., description="Mandatory website url to read the file") + css_element: str = Field(..., description="Mandatory css reference for element to scrape from the website") + +class ScrapeElementFromWebsiteTool(BaseTool): + name: str = "Read a website content" + description: str = "A tool that can be used to read a website content." + args_schema: Type[BaseModel] = ScrapeElementFromWebsiteToolSchema + website_url: Optional[str] = None + cookies: Optional[dict] = None + css_element: Optional[str] = None + headers: Optional[dict] = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': 'https://www.google.com/', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Accept-Encoding': 'gzip, deflate, br' + } + + def __init__(self, website_url: Optional[str] = None, cookies: Optional[dict] = None, css_element: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if website_url is not None: + self.website_url = website_url + self.css_element = css_element + self.description = f"A tool that can be used to read {website_url}'s content." + self.args_schema = FixedScrapeElementFromWebsiteToolSchema + self._generate_description() + if cookies is not None: + self.cookies = {cookies["name"]: os.getenv(cookies["value"])} + + def _run( + self, + **kwargs: Any, + ) -> Any: + website_url = kwargs.get('website_url', self.website_url) + css_element = kwargs.get('css_element', self.css_element) + page = requests.get(website_url, headers=self.headers, cookies=self.cookies if self.cookies else {}) + parsed = BeautifulSoup(page.content, "html.parser") + elements = parsed.select(css_element) + return "\n".join([element.get_text() for element in elements]) + + + diff --git a/squadai/squadai_tools/tools/scrape_website_tool/README.md b/squadai/squadai_tools/tools/scrape_website_tool/README.md new file mode 100644 index 0000000..8895529 --- /dev/null +++ b/squadai/squadai_tools/tools/scrape_website_tool/README.md @@ -0,0 +1,24 @@ +# ScrapeWebsiteTool + +## Description +A tool designed to extract and read the content of a specified website. It is capable of handling various types of web pages by making HTTP requests and parsing the received HTML content. This tool can be particularly useful for web scraping tasks, data collection, or extracting specific information from websites. + +## Installation +Install the squadai_tools package +```shell +pip install 'squadai[tools]' +``` + +## Example +```python +from squadai_tools import ScrapeWebsiteTool + +# To enable scrapping any website it finds during it's execution +tool = ScrapeWebsiteTool() + +# Initialize the tool with the website URL, so the agent can only scrap the content of the specified website +tool = ScrapeWebsiteTool(website_url='https://www.example.com') +``` + +## Arguments +- `website_url` : Mandatory website URL to read the file. This is the primary input for the tool, specifying which website's content should be scraped and read. \ No newline at end of file diff --git a/squadai/squadai_tools/tools/scrape_website_tool/scrape_website_tool.py b/squadai/squadai_tools/tools/scrape_website_tool/scrape_website_tool.py new file mode 100644 index 0000000..92f84cb --- /dev/null +++ b/squadai/squadai_tools/tools/scrape_website_tool/scrape_website_tool.py @@ -0,0 +1,59 @@ +import os +import requests +from bs4 import BeautifulSoup +from typing import Optional, Type, Any +from pydantic.v1 import BaseModel, Field +from ..base_tool import BaseTool + +class FixedScrapeWebsiteToolSchema(BaseModel): + """Input for ScrapeWebsiteTool.""" + pass + +class ScrapeWebsiteToolSchema(FixedScrapeWebsiteToolSchema): + """Input for ScrapeWebsiteTool.""" + website_url: str = Field(..., description="Mandatory website url to read the file") + +class ScrapeWebsiteTool(BaseTool): + name: str = "Read website content" + description: str = "A tool that can be used to read a website content." + args_schema: Type[BaseModel] = ScrapeWebsiteToolSchema + website_url: Optional[str] = None + cookies: Optional[dict] = None + headers: Optional[dict] = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': 'https://www.google.com/', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1' + } + + def __init__(self, website_url: Optional[str] = None, cookies: Optional[dict] = None, **kwargs): + super().__init__(**kwargs) + if website_url is not None: + self.website_url = website_url + self.description = f"A tool that can be used to read {website_url}'s content." + self.args_schema = FixedScrapeWebsiteToolSchema + self._generate_description() + if cookies is not None: + self.cookies = {cookies["name"]: os.getenv(cookies["value"])} + + def _run( + self, + **kwargs: Any, + ) -> Any: + website_url = kwargs.get('website_url', self.website_url) + page = requests.get( + website_url, + timeout=15, + headers=self.headers, + cookies=self.cookies if self.cookies else {} + ) + + page.encoding = page.apparent_encoding + parsed = BeautifulSoup(page.text, "html.parser") + + text = parsed.get_text() + text = '\n'.join([i for i in text.split('\n') if i.strip() != '']) + text = ' '.join([i for i in text.split(' ') if i.strip() != '']) + return text diff --git a/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/README.md b/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/README.md new file mode 100644 index 0000000..3af7076 --- /dev/null +++ b/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/README.md @@ -0,0 +1,57 @@ +# ScrapflyScrapeWebsiteTool + +## Description +[ScrapFly](https://scrapfly.io/) is a web scraping API with headless browser capabilities, proxies, and anti-bot bypass. It allows for extracting web page data into accessible LLM markdown or text. + +## Setup and Installation +1. **Install ScrapFly Python SDK**: Install `scrapfly-sdk` Python package is installed to use the ScrapFly Web Loader. Install it via pip with the following command: + + ```bash + pip install scrapfly-sdk + ``` + +2. **API Key**: Register for free from [scrapfly.io/register](https://www.scrapfly.io/register/) to obtain your API key. + +## Example Usage + +Utilize the ScrapflyScrapeWebsiteTool as follows to retrieve a web page data as text, markdown (LLM accissible) or HTML: + +```python +from squadai_tools import ScrapflyScrapeWebsiteTool + +tool = ScrapflyScrapeWebsiteTool( + api_key="Your ScrapFly API key" +) + +result = tool._run( + url="https://web-scraping.dev/products", + scrape_format="markdown", + ignore_scrape_failures=True +) +``` + +## Additional Arguments +The ScrapflyScrapeWebsiteTool also allows passigng ScrapeConfig object for customizing the scrape request. See the [API params documentation](https://scrapfly.io/docs/scrape-api/getting-started) for the full feature details and their API params: +```python +from squadai_tools import ScrapflyScrapeWebsiteTool + +tool = ScrapflyScrapeWebsiteTool( + api_key="Your ScrapFly API key" +) + +scrapfly_scrape_config = { + "asp": True, # Bypass scraping blocking and solutions, like Cloudflare + "render_js": True, # Enable JavaScript rendering with a cloud headless browser + "proxy_pool": "public_residential_pool", # Select a proxy pool (datacenter or residnetial) + "country": "us", # Select a proxy location + "auto_scroll": True, # Auto scroll the page + "js": "" # Execute custom JavaScript code by the headless browser +} + +result = tool._run( + url="https://web-scraping.dev/products", + scrape_format="markdown", + ignore_scrape_failures=True, + scrape_config=scrapfly_scrape_config +) +``` \ No newline at end of file diff --git a/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/scrapfly_scrape_website_tool.py b/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/scrapfly_scrape_website_tool.py new file mode 100644 index 0000000..f7d2771 --- /dev/null +++ b/squadai/squadai_tools/tools/scrapfly_scrape_website_tool/scrapfly_scrape_website_tool.py @@ -0,0 +1,47 @@ +import logging + +from typing import Optional, Any, Type, Dict, Literal +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +logger = logging.getLogger(__file__) + +class ScrapflyScrapeWebsiteToolSchema(BaseModel): + url: str = Field(description="Webpage URL") + scrape_format: Optional[Literal["raw", "markdown", "text"]] = Field(default="markdown", description="Webpage extraction format") + scrape_config: Optional[Dict[str, Any]] = Field(default=None, description="Scrapfly request scrape config") + ignore_scrape_failures: Optional[bool] = Field(default=None, description="whether to ignore failures") + +class ScrapflyScrapeWebsiteTool(BaseTool): + name: str = "Scrapfly web scraping API tool" + description: str = "Scrape a webpage url using Scrapfly and return its content as markdown or text" + args_schema: Type[BaseModel] = ScrapflyScrapeWebsiteToolSchema + api_key: str = None + scrapfly: Optional[Any] = None + + def __init__(self, api_key: str): + super().__init__() + try: + from scrapfly import ScrapflyClient + except ImportError: + raise ImportError( + "`scrapfly` package not found, please run `pip install scrapfly-sdk`" + ) + self.scrapfly = ScrapflyClient(key=api_key) + + def _run(self, url: str, scrape_format: str = "markdown", scrape_config: Optional[Dict[str, Any]] = None, ignore_scrape_failures: Optional[bool] = None): + from scrapfly import ScrapeApiResponse, ScrapeConfig + + scrape_config = scrape_config if scrape_config is not None else {} + try: + response: ScrapeApiResponse = self.scrapfly.scrape( + ScrapeConfig(url, format=scrape_format, **scrape_config) + ) + return response.scrape_result["content"] + except Exception as e: + if ignore_scrape_failures: + logger.error(f"Error fetching data from {url}, exception: {e}") + return None + else: + raise e + diff --git a/squadai/squadai_tools/tools/selenium_scraping_tool/README.md b/squadai/squadai_tools/tools/selenium_scraping_tool/README.md new file mode 100644 index 0000000..e5856f3 --- /dev/null +++ b/squadai/squadai_tools/tools/selenium_scraping_tool/README.md @@ -0,0 +1,33 @@ +# SeleniumScrapingTool + +## Description +This tool is designed for efficient web scraping, enabling users to extract content from web pages. It supports targeted scraping by allowing the specification of a CSS selector for desired elements. The flexibility of the tool enables it to be used on any website URL provided by the user, making it a versatile tool for various web scraping needs. + +## Installation +Install the squadai_tools package +``` +pip install 'squadai[tools]' +``` + +## Example +```python +from squadai_tools import SeleniumScrapingTool + +# Example 1: Scrape any website it finds during its execution +tool = SeleniumScrapingTool() + +# Example 2: Scrape the entire webpage +tool = SeleniumScrapingTool(website_url='https://example.com') + +# Example 3: Scrape a specific CSS element from the webpage +tool = SeleniumScrapingTool(website_url='https://example.com', css_element='.main-content') + +# Example 4: Scrape using optional parameters for customized scraping +tool = SeleniumScrapingTool(website_url='https://example.com', css_element='.main-content', cookie={'name': 'user', 'value': 'John Doe'}) +``` + +## Arguments +- `website_url`: Mandatory. The URL of the website to scrape. +- `css_element`: Mandatory. The CSS selector for a specific element to scrape from the website. +- `cookie`: Optional. A dictionary containing cookie information. This parameter allows the tool to simulate a session with cookie information, providing access to content that may be restricted to logged-in users. +- `wait_time`: Optional. The number of seconds the tool waits after loading the website and after setting a cookie, before scraping the content. This allows for dynamic content to load properly. diff --git a/squadai/squadai_tools/tools/selenium_scraping_tool/selenium_scraping_tool.py b/squadai/squadai_tools/tools/selenium_scraping_tool/selenium_scraping_tool.py new file mode 100644 index 0000000..6bf8ff5 --- /dev/null +++ b/squadai/squadai_tools/tools/selenium_scraping_tool/selenium_scraping_tool.py @@ -0,0 +1,77 @@ +from typing import Optional, Type, Any +import time +from pydantic.v1 import BaseModel, Field + +from bs4 import BeautifulSoup +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options + +from ..base_tool import BaseTool + +class FixedSeleniumScrapingToolSchema(BaseModel): + """Input for SeleniumScrapingTool.""" + pass + +class SeleniumScrapingToolSchema(FixedSeleniumScrapingToolSchema): + """Input for SeleniumScrapingTool.""" + website_url: str = Field(..., description="Mandatory website url to read the file") + css_element: str = Field(..., description="Mandatory css reference for element to scrape from the website") + +class SeleniumScrapingTool(BaseTool): + name: str = "Read a website content" + description: str = "A tool that can be used to read a website content." + args_schema: Type[BaseModel] = SeleniumScrapingToolSchema + website_url: Optional[str] = None + driver: Optional[Any] = webdriver.Chrome + cookie: Optional[dict] = None + wait_time: Optional[int] = 3 + css_element: Optional[str] = None + + def __init__(self, website_url: Optional[str] = None, cookie: Optional[dict] = None, css_element: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if cookie is not None: + self.cookie = cookie + + if css_element is not None: + self.css_element = css_element + + if website_url is not None: + self.website_url = website_url + self.description = f"A tool that can be used to read {website_url}'s content." + self.args_schema = FixedSeleniumScrapingToolSchema + + self._generate_description() + def _run( + self, + **kwargs: Any, + ) -> Any: + website_url = kwargs.get('website_url', self.website_url) + css_element = kwargs.get('css_element', self.css_element) + driver = self._create_driver(website_url, self.cookie, self.wait_time) + + content = [] + if css_element is None or css_element.strip() == "": + body_text = driver.find_element(By.TAG_NAME, "body").text + content.append(body_text) + else: + for element in driver.find_elements(By.CSS_SELECTOR, css_element): + content.append(element.text) + driver.close() + return "\n".join(content) + + def _create_driver(self, url, cookie, wait_time): + options = Options() + options.add_argument("--headless") + driver = self.driver(options=options) + driver.get(url) + time.sleep(wait_time) + if cookie: + driver.add_cookie(cookie) + time.sleep(wait_time) + driver.get(url) + time.sleep(wait_time) + return driver + + def close(self): + self.driver.close() \ No newline at end of file diff --git a/squadai/squadai_tools/tools/serper_dev_tool/README.md b/squadai/squadai_tools/tools/serper_dev_tool/README.md new file mode 100644 index 0000000..5d15562 --- /dev/null +++ b/squadai/squadai_tools/tools/serper_dev_tool/README.md @@ -0,0 +1,30 @@ +# SerperDevTool Documentation + +## Description +This tool is designed to perform a semantic search for a specified query from a text's content across the internet. It utilizes the `serper.dev` API to fetch and display the most relevant search results based on the query provided by the user. + +## Installation +To incorporate this tool into your project, follow the installation instructions below: +```shell +pip install 'squadai[tools]' +``` + +## Example +The following example demonstrates how to initialize the tool and execute a search with a given query: + +```python +from squadai_tools import SerperDevTool + +# Initialize the tool for internet searching capabilities +tool = SerperDevTool() +``` + +## Steps to Get Started +To effectively use the `SerperDevTool`, follow these steps: + +1. **Package Installation**: Confirm that the `squadai[tools]` package is installed in your Python environment. +2. **API Key Acquisition**: Acquire a `serper.dev` API key by registering for a free account at `serper.dev`. +3. **Environment Configuration**: Store your obtained API key in an environment variable named `SERPER_API_KEY` to facilitate its use by the tool. + +## Conclusion +By integrating the `SerperDevTool` into Python projects, users gain the ability to conduct real-time, relevant searches across the internet directly from their applications. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is streamlined and straightforward. diff --git a/squadai/squadai_tools/tools/serper_dev_tool/serper_dev_tool.py b/squadai/squadai_tools/tools/serper_dev_tool/serper_dev_tool.py new file mode 100644 index 0000000..99661e8 --- /dev/null +++ b/squadai/squadai_tools/tools/serper_dev_tool/serper_dev_tool.py @@ -0,0 +1,80 @@ +import datetime +import os +import json +import requests + +from typing import Optional, Type, Any +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +def _save_results_to_file(content: str) -> None: + """Saves the search results to a file.""" + filename = f"search_results_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt" + with open(filename, 'w') as file: + file.write(content) + print(f"Results saved to {filename}") + + +class SerperDevToolSchema(BaseModel): + """Input for SerperDevTool.""" + search_query: str = Field(..., description="Mandatory search query you want to use to search the internet") + +class SerperDevTool(BaseTool): + name: str = "Search the internet" + description: str = "A tool that can be used to search the internet with a search_query." + args_schema: Type[BaseModel] = SerperDevToolSchema + search_url: str = "https://google.serper.dev/search" + country: Optional[str] = '' + location: Optional[str] = '' + locale: Optional[str] = '' + n_results: int = 10 + save_file: bool = False + + def _run( + self, + **kwargs: Any, + ) -> Any: + + search_query = kwargs.get('search_query') or kwargs.get('query') + save_file = kwargs.get('save_file', self.save_file) + n_results = kwargs.get('n_results', self.n_results) + + payload = { "q": search_query, "num": n_results } + + if self.country != '': + payload["gl"] = self.country + if self.location != '': + payload["location"] = self.location + if self.locale != '': + payload["hl"] = self.locale + + payload = json.dumps(payload) + + headers = { + 'X-API-KEY': os.environ['SERPER_API_KEY'], + 'content-type': 'application/json' + } + + response = requests.request("POST", self.search_url, headers=headers, data=payload) + results = response.json() + + if 'organic' in results: + results = results['organic'][:self.n_results] + string = [] + for result in results: + try: + string.append('\n'.join([ + f"Title: {result['title']}", + f"Link: {result['link']}", + f"Snippet: {result['snippet']}", + "---" + ])) + except KeyError: + continue + + content = '\n'.join(string) + if save_file: + _save_results_to_file(content) + return f"\nSearch results: {content}\n" + else: + return results diff --git a/squadai/squadai_tools/tools/serply_api_tool/README.md b/squadai/squadai_tools/tools/serply_api_tool/README.md new file mode 100644 index 0000000..446b941 --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/README.md @@ -0,0 +1,117 @@ +# Serply API Documentation + +## Description +This tool is designed to perform a web/news/scholar search for a specified query from a text's content across the internet. It utilizes the [Serply.io](https://serply.io) API to fetch and display the most relevant search results based on the query provided by the user. + +## Installation + +To incorporate this tool into your project, follow the installation instructions below: +```shell +pip install 'squadai[tools]' +``` + +## Examples + +## Web Search +The following example demonstrates how to initialize the tool and execute a search the web with a given query: + +```python +from squadai_tools import SerplyWebSearchTool + +# Initialize the tool for internet searching capabilities +tool = SerplyWebSearchTool() + +# increase search limits to 100 results +tool = SerplyWebSearchTool(limit=100) + + +# change results language (fr - French) +tool = SerplyWebSearchTool(hl="fr") +``` + +## News Search +The following example demonstrates how to initialize the tool and execute a search news with a given query: + +```python +from squadai_tools import SerplyNewsSearchTool + +# Initialize the tool for internet searching capabilities +tool = SerplyNewsSearchTool() + +# change country news (JP - Japan) +tool = SerplyNewsSearchTool(proxy_location="JP") +``` + +## Scholar Search +The following example demonstrates how to initialize the tool and execute a search scholar articles a given query: + +```python +from squadai_tools import SerplyScholarSearchTool + +# Initialize the tool for internet searching capabilities +tool = SerplyScholarSearchTool() + +# change country news (GB - Great Britain) +tool = SerplyScholarSearchTool(proxy_location="GB") +``` + +## Job Search +The following example demonstrates how to initialize the tool and searching for jobs in the USA: + +```python +from squadai_tools import SerplyJobSearchTool + +# Initialize the tool for internet searching capabilities +tool = SerplyJobSearchTool() +``` + + +## Web Page To Markdown +The following example demonstrates how to initialize the tool and fetch a web page and convert it to markdown: + +```python +from squadai_tools import SerplyWebpageToMarkdownTool + +# Initialize the tool for internet searching capabilities +tool = SerplyWebpageToMarkdownTool() + +# change country make request from (DE - Germany) +tool = SerplyWebpageToMarkdownTool(proxy_location="DE") +``` + +## Combining Multiple Tools + +The following example demonstrates performing a Google search to find relevant articles. Then, convert those articles to markdown format for easier extraction of key points. + +```python +from squadai import Agent +from squadai_tools import SerplyWebSearchTool, SerplyWebpageToMarkdownTool + +search_tool = SerplyWebSearchTool() +convert_to_markdown = SerplyWebpageToMarkdownTool() + +# Creating a senior researcher agent with memory and verbose mode +researcher = Agent( + role='Senior Researcher', + goal='Uncover groundbreaking technologies in {topic}', + verbose=True, + memory=True, + backstory=( + "Driven by curiosity, you're at the forefront of" + "innovation, eager to explore and share knowledge that could change" + "the world." + ), + tools=[search_tool, convert_to_markdown], + allow_delegation=True +) +``` + +## Steps to Get Started +To effectively use the `SerplyApiTool`, follow these steps: + +1. **Package Installation**: Confirm that the `squadai[tools]` package is installed in your Python environment. +2. **API Key Acquisition**: Acquire a `serper.dev` API key by registering for a free account at [Serply.io](https://serply.io). +3. **Environment Configuration**: Store your obtained API key in an environment variable named `SERPLY_API_KEY` to facilitate its use by the tool. + +## Conclusion +By integrating the `SerplyApiTool` into Python projects, users gain the ability to conduct real-time searches, relevant news across the internet directly from their applications. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is streamlined and straightforward. diff --git a/squadai/squadai_tools/tools/serply_api_tool/serply_job_search_tool.py b/squadai/squadai_tools/tools/serply_api_tool/serply_job_search_tool.py new file mode 100644 index 0000000..0f7b623 --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/serply_job_search_tool.py @@ -0,0 +1,75 @@ +import os +import requests +from urllib.parse import urlencode +from typing import Type, Any, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.rag.rag_tool import RagTool + + +class SerplyJobSearchToolSchema(BaseModel): + """Input for Job Search.""" + search_query: str = Field(..., description="Mandatory search query you want to use to fetch jobs postings.") + + +class SerplyJobSearchTool(RagTool): + name: str = "Job Search" + description: str = "A tool to perform to perform a job search in the US with a search_query." + args_schema: Type[BaseModel] = SerplyJobSearchToolSchema + request_url: str = "https://api.serply.io/v1/job/search/" + proxy_location: Optional[str] = "US" + """ + proxy_location: (str): Where to get jobs, specifically for a specific country results. + - Currently only supports US + """ + headers: Optional[dict] = {} + + def __init__( + self, + **kwargs + ): + super().__init__(**kwargs) + self.headers = { + "X-API-KEY": os.environ["SERPLY_API_KEY"], + "User-Agent": "squad-tools", + "X-Proxy-Location": self.proxy_location + } + + def _run( + self, + **kwargs: Any, + ) -> Any: + query_payload = {} + + if "query" in kwargs: + query_payload["q"] = kwargs["query"] + elif "search_query" in kwargs: + query_payload["q"] = kwargs["search_query"] + + # build the url + url = f"{self.request_url}{urlencode(query_payload)}" + + response = requests.request("GET", url, headers=self.headers) + + jobs = response.json().get("jobs", "") + + if not jobs: + return "" + + string = [] + for job in jobs: + try: + string.append('\n'.join([ + f"Position: {job['position']}", + f"Employer: {job['employer']}", + f"Location: {job['location']}", + f"Link: {job['link']}", + f"""Highest: {', '.join([h for h in job['highlights']])}""", + f"Is Remote: {job['is_remote']}", + f"Is Hybrid: {job['is_remote']}", + "---" + ])) + except KeyError: + continue + + content = '\n'.join(string) + return f"\nSearch results: {content}\n" diff --git a/squadai/squadai_tools/tools/serply_api_tool/serply_news_search_tool.py b/squadai/squadai_tools/tools/serply_api_tool/serply_news_search_tool.py new file mode 100644 index 0000000..8414f82 --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/serply_news_search_tool.py @@ -0,0 +1,81 @@ +import os +import requests +from urllib.parse import urlencode +from typing import Type, Any, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class SerplyNewsSearchToolSchema(BaseModel): + """Input for Serply News Search.""" + search_query: str = Field(..., description="Mandatory search query you want to use to fetch news articles") + + +class SerplyNewsSearchTool(BaseTool): + name: str = "News Search" + description: str = "A tool to perform News article search with a search_query." + args_schema: Type[BaseModel] = SerplyNewsSearchToolSchema + search_url: str = "https://api.serply.io/v1/news/" + proxy_location: Optional[str] = "US" + headers: Optional[dict] = {} + limit: Optional[int] = 10 + + def __init__( + self, + limit: Optional[int] = 10, + proxy_location: Optional[str] = "US", + **kwargs + ): + """ + param: limit (int): The maximum number of results to return [10-100, defaults to 10] + proxy_location: (str): Where to get news, specifically for a specific country results. + ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'] (defaults to US) + """ + super().__init__(**kwargs) + self.limit = limit + self.proxy_location = proxy_location + self.headers = { + "X-API-KEY": os.environ["SERPLY_API_KEY"], + "User-Agent": "squad-tools", + "X-Proxy-Location": proxy_location + } + + def _run( + self, + **kwargs: Any, + ) -> Any: + # build query parameters + query_payload = {} + + if "query" in kwargs: + query_payload["q"] = kwargs["query"] + elif "search_query" in kwargs: + query_payload["q"] = kwargs["search_query"] + + # build the url + url = f"{self.search_url}{urlencode(query_payload)}" + + response = requests.request("GET", url, headers=self.headers) + results = response.json() + if "entries" in results: + results = results['entries'] + string = [] + for result in results[:self.limit]: + try: + # follow url + r = requests.get(result['link']) + final_link = r.history[-1].headers['Location'] + string.append('\n'.join([ + f"Title: {result['title']}", + f"Link: {final_link}", + f"Source: {result['source']['title']}", + f"Published: {result['published']}", + "---" + ])) + except KeyError: + continue + + content = '\n'.join(string) + return f"\nSearch results: {content}\n" + else: + return results diff --git a/squadai/squadai_tools/tools/serply_api_tool/serply_scholar_search_tool.py b/squadai/squadai_tools/tools/serply_api_tool/serply_scholar_search_tool.py new file mode 100644 index 0000000..fff607d --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/serply_scholar_search_tool.py @@ -0,0 +1,86 @@ +import os +import requests +from urllib.parse import urlencode +from typing import Type, Any, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class SerplyScholarSearchToolSchema(BaseModel): + """Input for Serply Scholar Search.""" + search_query: str = Field(..., description="Mandatory search query you want to use to fetch scholarly literature") + + +class SerplyScholarSearchTool(BaseTool): + name: str = "Scholar Search" + description: str = "A tool to perform scholarly literature search with a search_query." + args_schema: Type[BaseModel] = SerplyScholarSearchToolSchema + search_url: str = "https://api.serply.io/v1/scholar/" + hl: Optional[str] = "us" + proxy_location: Optional[str] = "US" + headers: Optional[dict] = {} + + def __init__( + self, + hl: str = "us", + proxy_location: Optional[str] = "US", + **kwargs + ): + """ + param: hl (str): host Language code to display results in + (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages) + proxy_location: (str): Specify the proxy location for the search, specifically for a specific country results. + ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'] (defaults to US) + """ + super().__init__(**kwargs) + self.hl = hl + self.proxy_location = proxy_location + self.headers = { + "X-API-KEY": os.environ["SERPLY_API_KEY"], + "User-Agent": "squad-tools", + "X-Proxy-Location": proxy_location + } + + def _run( + self, + **kwargs: Any, + ) -> Any: + query_payload = { + "hl": self.hl + } + + if "query" in kwargs: + query_payload["q"] = kwargs["query"] + elif "search_query" in kwargs: + query_payload["q"] = kwargs["search_query"] + + # build the url + url = f"{self.search_url}{urlencode(query_payload)}" + + response = requests.request("GET", url, headers=self.headers) + articles = response.json().get("articles", "") + + if not articles: + return "" + + string = [] + for article in articles: + try: + if "doc" in article: + link = article['doc']['link'] + else: + link = article['link'] + authors = [author['name'] for author in article['author']['authors']] + string.append('\n'.join([ + f"Title: {article['title']}", + f"Link: {link}", + f"Description: {article['description']}", + f"Cite: {article['cite']}", + f"Authors: {', '.join(authors)}", + "---" + ])) + except KeyError: + continue + + content = '\n'.join(string) + return f"\nSearch results: {content}\n" diff --git a/squadai/squadai_tools/tools/serply_api_tool/serply_web_search_tool.py b/squadai/squadai_tools/tools/serply_api_tool/serply_web_search_tool.py new file mode 100644 index 0000000..97c4561 --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/serply_web_search_tool.py @@ -0,0 +1,93 @@ +import os +import requests +from urllib.parse import urlencode +from typing import Type, Any, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + + +class SerplyWebSearchToolSchema(BaseModel): + """Input for Serply Web Search.""" + search_query: str = Field(..., description="Mandatory search query you want to use to Google search") + + +class SerplyWebSearchTool(BaseTool): + name: str = "Google Search" + description: str = "A tool to perform Google search with a search_query." + args_schema: Type[BaseModel] = SerplyWebSearchToolSchema + search_url: str = "https://api.serply.io/v1/search/" + hl: Optional[str] = "us" + limit: Optional[int] = 10 + device_type: Optional[str] = "desktop" + proxy_location: Optional[str] = "US" + query_payload: Optional[dict] = {} + headers: Optional[dict] = {} + + def __init__( + self, + hl: str = "us", + limit: int = 10, + device_type: str = "desktop", + proxy_location: str = "US", + **kwargs + ): + """ + param: query (str): The query to search for + param: hl (str): host Language code to display results in + (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages) + param: limit (int): The maximum number of results to return [10-100, defaults to 10] + param: device_type (str): desktop/mobile results (defaults to desktop) + proxy_location: (str): Where to perform the search, specifically for local/regional results. + ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'] (defaults to US) + """ + super().__init__(**kwargs) + + self.limit = limit + self.device_type = device_type + self.proxy_location = proxy_location + + # build query parameters + self.query_payload = { + "num": limit, + "gl": proxy_location.upper(), + "hl": hl.lower() + } + self.headers = { + "X-API-KEY": os.environ["SERPLY_API_KEY"], + "X-User-Agent": device_type, + "User-Agent": "squad-tools", + "X-Proxy-Location": proxy_location + } + + def _run( + self, + **kwargs: Any, + ) -> Any: + if "query" in kwargs: + self.query_payload["q"] = kwargs["query"] + elif "search_query" in kwargs: + self.query_payload["q"] = kwargs["search_query"] + + # build the url + url = f"{self.search_url}{urlencode(self.query_payload)}" + + response = requests.request("GET", url, headers=self.headers) + results = response.json() + if "results" in results: + results = results['results'] + string = [] + for result in results: + try: + string.append('\n'.join([ + f"Title: {result['title']}", + f"Link: {result['link']}", + f"Description: {result['description'].strip()}", + "---" + ])) + except KeyError: + continue + + content = '\n'.join(string) + return f"\nSearch results: {content}\n" + else: + return results diff --git a/squadai/squadai_tools/tools/serply_api_tool/serply_webpage_to_markdown_tool.py b/squadai/squadai_tools/tools/serply_api_tool/serply_webpage_to_markdown_tool.py new file mode 100644 index 0000000..1d450fc --- /dev/null +++ b/squadai/squadai_tools/tools/serply_api_tool/serply_webpage_to_markdown_tool.py @@ -0,0 +1,48 @@ +import os +import requests +from typing import Type, Any, Optional +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.rag.rag_tool import RagTool + + +class SerplyWebpageToMarkdownToolSchema(BaseModel): + """Input for Serply Search.""" + url: str = Field(..., description="Mandatory url you want to use to fetch and convert to markdown") + + +class SerplyWebpageToMarkdownTool(RagTool): + name: str = "Webpage to Markdown" + description: str = "A tool to perform convert a webpage to markdown to make it easier for LLMs to understand" + args_schema: Type[BaseModel] = SerplyWebpageToMarkdownToolSchema + request_url: str = "https://api.serply.io/v1/request" + proxy_location: Optional[str] = "US" + headers: Optional[dict] = {} + + def __init__( + self, + proxy_location: Optional[str] = "US", + **kwargs + ): + """ + proxy_location: (str): Where to perform the search, specifically for a specific country results. + ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'] (defaults to US) + """ + super().__init__(**kwargs) + self.proxy_location = proxy_location + self.headers = { + "X-API-KEY": os.environ["SERPLY_API_KEY"], + "User-Agent": "squad-tools", + "X-Proxy-Location": proxy_location + } + + def _run( + self, + **kwargs: Any, + ) -> Any: + data = { + "url": kwargs["url"], + "method": "GET", + "response_type": "markdown" + } + response = requests.request("POST", self.request_url, headers=self.headers, json=data) + return response.text diff --git a/squadai/squadai_tools/tools/spider_tool/README.md b/squadai/squadai_tools/tools/spider_tool/README.md new file mode 100644 index 0000000..c918a14 --- /dev/null +++ b/squadai/squadai_tools/tools/spider_tool/README.md @@ -0,0 +1,81 @@ +# SpiderTool + +## Description + +[Spider](https://spider.cloud/?ref=squadai) is the [fastest](https://github.com/spider-rs/spider/blob/main/benches/BENCHMARKS.md#benchmark-results) open source scraper and crawler that returns LLM-ready data. It converts any website into pure HTML, markdown, metadata or text while enabling you to crawl with custom actions using AI. + +## Installation + +To use the Spider API you need to download the [Spider SDK](https://pypi.org/project/spider-client/) and the squadai[tools] SDK too: + +```python +pip install spider-client 'squadai[tools]' +``` + +## Example + +This example shows you how you can use the Spider tool to enable your agent to scrape and crawl websites. The data returned from the Spider API is already LLM-ready, so no need to do any cleaning there. + +```python +from squadai_tools import SpiderTool + +def main(): + spider_tool = SpiderTool() + + searcher = Agent( + role="Web Research Expert", + goal="Find related information from specific URL's", + backstory="An expert web researcher that uses the web extremely well", + tools=[spider_tool], + verbose=True, + ) + + return_metadata = Task( + description="Scrape https://spider.cloud with a limit of 1 and enable metadata", + expected_output="Metadata and 10 word summary of spider.cloud", + agent=searcher + ) + + squad = Squad( + agents=[searcher], + tasks=[ + return_metadata, + ], + verbose=2 + ) + + squad.kickoff() + +if __name__ == "__main__": + main() +``` + +## Arguments + +- `api_key` (string, optional): Specifies Spider API key. If not specified, it looks for `SPIDER_API_KEY` in environment variables. +- `params` (object, optional): Optional parameters for the request. Defaults to `{"return_format": "markdown"}` to return the website's content in a format that fits LLMs better. + - `request` (string): The request type to perform. Possible values are `http`, `chrome`, and `smart`. Use `smart` to perform an HTTP request by default until JavaScript rendering is needed for the HTML. + - `limit` (int): The maximum number of pages allowed to crawl per website. Remove the value or set it to `0` to crawl all pages. + - `depth` (int): The crawl limit for maximum depth. If `0`, no limit will be applied. + - `cache` (bool): Use HTTP caching for the crawl to speed up repeated runs. Default is `true`. + - `budget` (object): Object that has paths with a counter for limiting the amount of pages example `{"*":1}` for only crawling the root page. + - `locale` (string): The locale to use for request, example `en-US`. + - `cookies` (string): Add HTTP cookies to use for request. + - `stealth` (bool): Use stealth mode for headless chrome request to help prevent being blocked. The default is `true` on chrome. + - `headers` (object): Forward HTTP headers to use for all request. The object is expected to be a map of key value pairs. + - `metadata` (bool): Boolean to store metadata about the pages and content found. This could help improve AI interopt. Defaults to `false` unless you have the website already stored with the configuration enabled. + - `viewport` (object): Configure the viewport for chrome. Defaults to `800x600`. + - `encoding` (string): The type of encoding to use like `UTF-8`, `SHIFT_JIS`, or etc. + - `subdomains` (bool): Allow subdomains to be included. Default is `false`. + - `user_agent` (string): Add a custom HTTP user agent to the request. By default this is set to a random agent. + - `store_data` (bool): Boolean to determine if storage should be used. If set this takes precedence over `storageless`. Defaults to `false`. + - `gpt_config` (object): Use AI to generate actions to perform during the crawl. You can pass an array for the `"prompt"` to chain steps. + - `fingerprint` (bool): Use advanced fingerprint for chrome. + - `storageless` (bool): Boolean to prevent storing any type of data for the request including storage and AI vectors embedding. Defaults to `false` unless you have the website already stored. + - `readability` (bool): Use [readability](https://github.com/mozilla/readability) to pre-process the content for reading. This may drastically improve the content for LLM usage. + `return_format` (string): The format to return the data in. Possible values are `markdown`, `raw`, `text`, and `html2text`. Use `raw` to return the default format of the page like HTML etc. + - `proxy_enabled` (bool): Enable high performance premium proxies for the request to prevent being blocked at the network level. + - `query_selector` (string): The CSS query selector to use when extracting content from the markup. + - `full_resources` (bool): Crawl and download all the resources for a website. + - `request_timeout` (int): The timeout to use for request. Timeouts can be from `5-60`. The default is `30` seconds. + - `run_in_background` (bool): Run the request in the background. Useful if storing data and wanting to trigger crawls to the dashboard. This has no effect if storageless is set. diff --git a/squadai/squadai_tools/tools/spider_tool/spider_tool.py b/squadai/squadai_tools/tools/spider_tool/spider_tool.py new file mode 100644 index 0000000..d593bcb --- /dev/null +++ b/squadai/squadai_tools/tools/spider_tool/spider_tool.py @@ -0,0 +1,59 @@ +from typing import Optional, Any, Type, Dict, Literal +from pydantic.v1 import BaseModel, Field +from squadai.squadai_tools.tools.base_tool import BaseTool + +class SpiderToolSchema(BaseModel): + url: str = Field(description="Website URL") + params: Optional[Dict[str, Any]] = Field( + description="Set additional params. Options include:\n" + "- `limit`: Optional[int] - The maximum number of pages allowed to crawl per website. Remove the value or set it to `0` to crawl all pages.\n" + "- `depth`: Optional[int] - The crawl limit for maximum depth. If `0`, no limit will be applied.\n" + "- `metadata`: Optional[bool] - Boolean to include metadata or not. Defaults to `False` unless set to `True`. If the user wants metadata, include params.metadata = True.\n" + "- `query_selector`: Optional[str] - The CSS query selector to use when extracting content from the markup.\n" + ) + mode: Literal["scrape", "crawl"] = Field( + default="scrape", + description="Mode, the only two allowed modes are `scrape` or `crawl`. Use `scrape` to scrape a single page and `crawl` to crawl the entire website following subpages. These modes are the only allowed values even when ANY params is set." + ) + +class SpiderTool(BaseTool): + name: str = "Spider scrape & crawl tool" + description: str = "Scrape & Crawl any url and return LLM-ready data." + args_schema: Type[BaseModel] = SpiderToolSchema + api_key: Optional[str] = None + spider: Optional[Any] = None + + def __init__(self, api_key: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + try: + from spider import Spider # type: ignore + except ImportError: + raise ImportError( + "`spider-client` package not found, please run `pip install spider-client`" + ) + + self.spider = Spider(api_key=api_key) + + def _run( + self, + url: str, + params: Optional[Dict[str, Any]] = None, + mode: Optional[Literal["scrape", "crawl"]] = "scrape" + ): + if mode not in ["scrape", "crawl"]: + raise ValueError( + "Unknown mode in `mode` parameter, `scrape` or `crawl` are the allowed modes" + ) + + # Ensure 'return_format': 'markdown' is always included + if params: + params["return_format"] = "markdown" + else: + params = {"return_format": "markdown"} + + action = ( + self.spider.scrape_url if mode == "scrape" else self.spider.crawl_url + ) + spider_docs = action(url=url, params=params) + + return spider_docs diff --git a/squadai/squadai_tools/tools/txt_search_tool/README.md b/squadai/squadai_tools/tools/txt_search_tool/README.md new file mode 100644 index 0000000..f8e16fa --- /dev/null +++ b/squadai/squadai_tools/tools/txt_search_tool/README.md @@ -0,0 +1,59 @@ +# TXTSearchTool + +## Description +This tool is used to perform a RAG (Retrieval-Augmented Generation) search within the content of a text file. It allows for semantic searching of a query within a specified text file's content, making it an invaluable resource for quickly extracting information or finding specific sections of text based on the query provided. + +## Installation +To use the TXTSearchTool, you first need to install the squadai_tools package. This can be done using pip, a package manager for Python. Open your terminal or command prompt and enter the following command: + +```shell +pip install 'squadai[tools]' +``` + +This command will download and install the TXTSearchTool along with any necessary dependencies. + +## Example +The following example demonstrates how to use the TXTSearchTool to search within a text file. This example shows both the initialization of the tool with a specific text file and the subsequent search within that file's content. + +```python +from squadai_tools import TXTSearchTool + +# Initialize the tool to search within any text file's content the agent learns about during its execution +tool = TXTSearchTool() + +# OR + +# Initialize the tool with a specific text file, so the agent can search within the given text file's content +tool = TXTSearchTool(txt='path/to/text/file.txt') +``` + +## Arguments +- `txt` (str): **Optinal**. The path to the text file you want to search. This argument is only required if the tool was not initialized with a specific text file; otherwise, the search will be conducted within the initially provided text file. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = TXTSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/txt_search_tool/txt_search_tool.py b/squadai/squadai_tools/tools/txt_search_tool/txt_search_tool.py new file mode 100644 index 0000000..5dbaed4 --- /dev/null +++ b/squadai/squadai_tools/tools/txt_search_tool/txt_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedTXTSearchToolSchema(BaseModel): + """Input for TXTSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the txt's content", + ) + + +class TXTSearchToolSchema(FixedTXTSearchToolSchema): + """Input for TXTSearchTool.""" + + txt: str = Field(..., description="Mandatory txt path you want to search") + + +class TXTSearchTool(RagTool): + name: str = "Search a txt's content" + description: str = ( + "A tool that can be used to semantic search a query from a txt's content." + ) + args_schema: Type[BaseModel] = TXTSearchToolSchema + + def __init__(self, txt: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if txt is not None: + self.add(txt) + self.description = f"A tool that can be used to semantic search a query the {txt} txt's content." + self.args_schema = FixedTXTSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.TEXT_FILE + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "txt" in kwargs: + self.add(kwargs["txt"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/vision_tool/README.md b/squadai/squadai_tools/tools/vision_tool/README.md new file mode 100644 index 0000000..91cf9c0 --- /dev/null +++ b/squadai/squadai_tools/tools/vision_tool/README.md @@ -0,0 +1,30 @@ +# Vision Tool + +## Description + +This tool is used to extract text from images. When passed to the agent it will extract the text from the image and then use it to generate a response, report or any other output. The URL or the PATH of the image should be passed to the Agent. + + +## Installation +Install the squadai_tools package +```shell +pip install 'squadai[tools]' +``` + +## Usage + +In order to use the VisionTool, the OpenAI API key should be set in the environment variable `OPENAI_API_KEY`. + +```python +from squadai_tools import VisionTool + +vision_tool = VisionTool() + +@agent +def researcher(self) -> Agent: + return Agent( + config=self.agents_config["researcher"], + allow_delegation=False, + tools=[vision_tool] + ) +``` diff --git a/squadai/squadai_tools/tools/vision_tool/vision_tool.py b/squadai/squadai_tools/tools/vision_tool/vision_tool.py new file mode 100644 index 0000000..53c7c26 --- /dev/null +++ b/squadai/squadai_tools/tools/vision_tool/vision_tool.py @@ -0,0 +1,93 @@ +import base64 +from typing import Type + +import requests +from squadai.squadai_tools.tools.base_tool import BaseTool +from openai import OpenAI +from pydantic.v1 import BaseModel + + +class ImagePromptSchema(BaseModel): + """Input for Vision Tool.""" + + image_path_url: str = "The image path or URL." + + +class VisionTool(BaseTool): + name: str = "Vision Tool" + description: str = ( + "This tool uses OpenAI's Vision API to describe the contents of an image." + ) + args_schema: Type[BaseModel] = ImagePromptSchema + + def _run_web_hosted_images(self, client, image_path_url: str) -> str: + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": {"url": image_path_url}, + }, + ], + } + ], + max_tokens=300, + ) + + return response.choices[0].message.content + + def _run_local_images(self, client, image_path_url: str) -> str: + base64_image = self._encode_image(image_path_url) + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {client.api_key}", + } + + payload = { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + }, + }, + ], + } + ], + "max_tokens": 300, + } + + response = requests.post( + "https://api.openai.com/v1/chat/completions", headers=headers, json=payload + ) + + return response.json()["choices"][0]["message"]["content"] + + def _run(self, **kwargs) -> str: + client = OpenAI() + + image_path_url = kwargs.get("image_path_url") + + if not image_path_url: + return "Image Path or URL is required." + + if "http" in image_path_url: + image_description = self._run_web_hosted_images(client, image_path_url) + else: + image_description = self._run_local_images(client, image_path_url) + + return image_description + + def _encode_image(self, image_path: str): + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") diff --git a/squadai/squadai_tools/tools/website_search/README.md b/squadai/squadai_tools/tools/website_search/README.md new file mode 100644 index 0000000..715d0fb --- /dev/null +++ b/squadai/squadai_tools/tools/website_search/README.md @@ -0,0 +1,57 @@ +# WebsiteSearchTool + +## Description +This tool is specifically crafted for conducting semantic searches within the content of a particular website. Leveraging a Retrieval-Augmented Generation (RAG) model, it navigates through the information provided on a given URL. Users have the flexibility to either initiate a search across any website known or discovered during its usage or to concentrate the search on a predefined, specific website. + +## Installation +Install the squadai_tools package by executing the following command in your terminal: + +```shell +pip install 'squadai[tools]' +``` + +## Example +To utilize the WebsiteSearchTool for different use cases, follow these examples: + +```python +from squadai_tools import WebsiteSearchTool + +# To enable the tool to search any website the agent comes across or learns about during its operation +tool = WebsiteSearchTool() + +# OR + +# To restrict the tool to only search within the content of a specific website. +tool = WebsiteSearchTool(website='https://example.com') +``` + +## Arguments +- `website` : An optional argument that specifies the valid website URL to perform the search on. This becomes necessary if the tool is initialized without a specific website. In the `WebsiteSearchToolSchema`, this argument is mandatory. However, in the `FixedWebsiteSearchToolSchema`, it becomes optional if a website is provided during the tool's initialization, as it will then only search within the predefined website's content. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = WebsiteSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/website_search/website_search_tool.py b/squadai/squadai_tools/tools/website_search/website_search_tool.py new file mode 100644 index 0000000..1ff587f --- /dev/null +++ b/squadai/squadai_tools/tools/website_search/website_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedWebsiteSearchToolSchema(BaseModel): + """Input for WebsiteSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search a specific website", + ) + + +class WebsiteSearchToolSchema(FixedWebsiteSearchToolSchema): + """Input for WebsiteSearchTool.""" + + website: str = Field( + ..., description="Mandatory valid website URL you want to search on" + ) + + +class WebsiteSearchTool(RagTool): + name: str = "Search in a specific website" + description: str = "A tool that can be used to semantic search a query from a specific URL content." + args_schema: Type[BaseModel] = WebsiteSearchToolSchema + + def __init__(self, website: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if website is not None: + self.add(website) + self.description = f"A tool that can be used to semantic search a query from {website} website content." + self.args_schema = FixedWebsiteSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.WEB_PAGE + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "website" in kwargs: + self.add(kwargs["website"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/xml_search_tool/README.md b/squadai/squadai_tools/tools/xml_search_tool/README.md new file mode 100644 index 0000000..12bf2d0 --- /dev/null +++ b/squadai/squadai_tools/tools/xml_search_tool/README.md @@ -0,0 +1,57 @@ +# XMLSearchTool + +## Description +The XMLSearchTool is a cutting-edge RAG tool engineered for conducting semantic searches within XML files. Ideal for users needing to parse and extract information from XML content efficiently, this tool supports inputting a search query and an optional XML file path. By specifying an XML path, users can target their search more precisely to the content of that file, thereby obtaining more relevant search outcomes. + +## Installation +To start using the XMLSearchTool, you must first install the squadai_tools package. This can be easily done with the following command: + +```shell +pip install 'squadai[tools]' +``` + +## Example +Here are two examples demonstrating how to use the XMLSearchTool. The first example shows searching within a specific XML file, while the second example illustrates initiating a search without predefining an XML path, providing flexibility in search scope. + +```python +from squadai_tools.tools.xml_search_tool import XMLSearchTool + +# Allow agents to search within any XML file's content as it learns about their paths during execution +tool = XMLSearchTool() + +# OR + +# Initialize the tool with a specific XML file path for exclusive search within that document +tool = XMLSearchTool(xml='path/to/your/xmlfile.xml') +``` + +## Arguments +- `xml`: This is the path to the XML file you wish to search. It is an optional parameter during the tool's initialization but must be provided either at initialization or as part of the `run` method's arguments to execute a search. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = XMLSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/xml_search_tool/xml_search_tool.py b/squadai/squadai_tools/tools/xml_search_tool/xml_search_tool.py new file mode 100644 index 0000000..0346d48 --- /dev/null +++ b/squadai/squadai_tools/tools/xml_search_tool/xml_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedXMLSearchToolSchema(BaseModel): + """Input for XMLSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the XML's content", + ) + + +class XMLSearchToolSchema(FixedXMLSearchToolSchema): + """Input for XMLSearchTool.""" + + xml: str = Field(..., description="Mandatory xml path you want to search") + + +class XMLSearchTool(RagTool): + name: str = "Search a XML's content" + description: str = ( + "A tool that can be used to semantic search a query from a XML's content." + ) + args_schema: Type[BaseModel] = XMLSearchToolSchema + + def __init__(self, xml: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if xml is not None: + self.add(xml) + self.description = f"A tool that can be used to semantic search a query the {xml} XML's content." + self.args_schema = FixedXMLSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.XML + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "xml" in kwargs: + self.add(kwargs["xml"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/youtube_channel_search_tool/README.md b/squadai/squadai_tools/tools/youtube_channel_search_tool/README.md new file mode 100644 index 0000000..c0590cf --- /dev/null +++ b/squadai/squadai_tools/tools/youtube_channel_search_tool/README.md @@ -0,0 +1,57 @@ +# YoutubeChannelSearchTool + +## Description +This tool is designed to perform semantic searches within a specific Youtube channel's content. Leveraging the RAG (Retrieval-Augmented Generation) methodology, it provides relevant search results, making it invaluable for extracting information or finding specific content without the need to manually sift through videos. It streamlines the search process within Youtube channels, catering to researchers, content creators, and viewers seeking specific information or topics. + +## Installation +To utilize the YoutubeChannelSearchTool, the `squadai_tools` package must be installed. Execute the following command in your shell to install: + +```shell +pip install 'squadai[tools]' +``` + +## Example +To begin using the YoutubeChannelSearchTool, follow the example below. This demonstrates initializing the tool with a specific Youtube channel handle and conducting a search within that channel's content. + +```python +from squadai_tools import YoutubeChannelSearchTool + +# Initialize the tool to search within any Youtube channel's content the agent learns about during its execution +tool = YoutubeChannelSearchTool() + +# OR + +# Initialize the tool with a specific Youtube channel handle to target your search +tool = YoutubeChannelSearchTool(youtube_channel_handle='@exampleChannel') +``` + +## Arguments +- `youtube_channel_handle` : A mandatory string representing the Youtube channel handle. This parameter is crucial for initializing the tool to specify the channel you want to search within. The tool is designed to only search within the content of the provided channel handle. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = YoutubeChannelSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/youtube_channel_search_tool/youtube_channel_search_tool.py b/squadai/squadai_tools/tools/youtube_channel_search_tool/youtube_channel_search_tool.py new file mode 100644 index 0000000..2edc002 --- /dev/null +++ b/squadai/squadai_tools/tools/youtube_channel_search_tool/youtube_channel_search_tool.py @@ -0,0 +1,63 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedYoutubeChannelSearchToolSchema(BaseModel): + """Input for YoutubeChannelSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the Youtube Channels content", + ) + + +class YoutubeChannelSearchToolSchema(FixedYoutubeChannelSearchToolSchema): + """Input for YoutubeChannelSearchTool.""" + + youtube_channel_handle: str = Field( + ..., description="Mandatory youtube_channel_handle path you want to search" + ) + + +class YoutubeChannelSearchTool(RagTool): + name: str = "Search a Youtube Channels content" + description: str = "A tool that can be used to semantic search a query from a Youtube Channels content." + args_schema: Type[BaseModel] = YoutubeChannelSearchToolSchema + + def __init__(self, youtube_channel_handle: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if youtube_channel_handle is not None: + self.add(youtube_channel_handle) + self.description = f"A tool that can be used to semantic search a query the {youtube_channel_handle} Youtube Channels content." + self.args_schema = FixedYoutubeChannelSearchToolSchema + self._generate_description() + + def add( + self, + youtube_channel_handle: str, + **kwargs: Any, + ) -> None: + if not youtube_channel_handle.startswith("@"): + youtube_channel_handle = f"@{youtube_channel_handle}" + + kwargs["data_type"] = DataType.YOUTUBE_CHANNEL + super().add(youtube_channel_handle, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "youtube_channel_handle" in kwargs: + self.add(kwargs["youtube_channel_handle"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squadai_tools/tools/youtube_video_search_tool/README.md b/squadai/squadai_tools/tools/youtube_video_search_tool/README.md new file mode 100644 index 0000000..e69d2d4 --- /dev/null +++ b/squadai/squadai_tools/tools/youtube_video_search_tool/README.md @@ -0,0 +1,60 @@ +# YoutubeVideoSearchTool + +## Description + +This tool is part of the `squadai_tools` package and is designed to perform semantic searches within Youtube video content, utilizing Retrieval-Augmented Generation (RAG) techniques. It is one of several "Search" tools in the package that leverage RAG for different sources. The YoutubeVideoSearchTool allows for flexibility in searches; users can search across any Youtube video content without specifying a video URL, or they can target their search to a specific Youtube video by providing its URL. + +## Installation + +To utilize the YoutubeVideoSearchTool, you must first install the `squadai_tools` package. This package contains the YoutubeVideoSearchTool among other utilities designed to enhance your data analysis and processing tasks. Install the package by executing the following command in your terminal: + +``` +pip install 'squadai[tools]' +``` + +## Example + +To integrate the YoutubeVideoSearchTool into your Python projects, follow the example below. This demonstrates how to use the tool both for general Youtube content searches and for targeted searches within a specific video's content. + +```python +from squadai_tools import YoutubeVideoSearchTool + +# General search across Youtube content without specifying a video URL, so the agent can search within any Youtube video content it learns about irs url during its operation +tool = YoutubeVideoSearchTool() + +# Targeted search within a specific Youtube video's content +tool = YoutubeVideoSearchTool(youtube_video_url='https://youtube.com/watch?v=example') +``` +## Arguments + +The YoutubeVideoSearchTool accepts the following initialization arguments: + +- `youtube_video_url`: An optional argument at initialization but required if targeting a specific Youtube video. It specifies the Youtube video URL path you want to search within. + +## Custom model and embeddings + +By default, the tool uses OpenAI for both embeddings and summarization. To customize the model, you can use a config dictionary as follows: + +```python +tool = YoutubeVideoSearchTool( + config=dict( + llm=dict( + provider="ollama", # or google, openai, anthropic, llama2, ... + config=dict( + model="llama2", + # temperature=0.5, + # top_p=1, + # stream=true, + ), + ), + embedder=dict( + provider="google", + config=dict( + model="models/embedding-001", + task_type="retrieval_document", + # title="Embeddings", + ), + ), + ) +) +``` diff --git a/squadai/squadai_tools/tools/youtube_video_search_tool/youtube_video_search_tool.py b/squadai/squadai_tools/tools/youtube_video_search_tool/youtube_video_search_tool.py new file mode 100644 index 0000000..77d2575 --- /dev/null +++ b/squadai/squadai_tools/tools/youtube_video_search_tool/youtube_video_search_tool.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Type + +from embedchain.models.data_type import DataType +from pydantic.v1 import BaseModel, Field + +from ..rag.rag_tool import RagTool + + +class FixedYoutubeVideoSearchToolSchema(BaseModel): + """Input for YoutubeVideoSearchTool.""" + + search_query: str = Field( + ..., + description="Mandatory search query you want to use to search the Youtube Video content", + ) + + +class YoutubeVideoSearchToolSchema(FixedYoutubeVideoSearchToolSchema): + """Input for YoutubeVideoSearchTool.""" + + youtube_video_url: str = Field( + ..., description="Mandatory youtube_video_url path you want to search" + ) + + +class YoutubeVideoSearchTool(RagTool): + name: str = "Search a Youtube Video content" + description: str = "A tool that can be used to semantic search a query from a Youtube Video content." + args_schema: Type[BaseModel] = YoutubeVideoSearchToolSchema + + def __init__(self, youtube_video_url: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + if youtube_video_url is not None: + self.add(youtube_video_url) + self.description = f"A tool that can be used to semantic search a query the {youtube_video_url} Youtube Video content." + self.args_schema = FixedYoutubeVideoSearchToolSchema + self._generate_description() + + def add( + self, + *args: Any, + **kwargs: Any, + ) -> None: + kwargs["data_type"] = DataType.YOUTUBE_VIDEO + super().add(*args, **kwargs) + + def _before_run( + self, + query: str, + **kwargs: Any, + ) -> Any: + if "youtube_video_url" in kwargs: + self.add(kwargs["youtube_video_url"]) + + def _run( + self, + search_query: str, + **kwargs: Any, + ) -> Any: + return super()._run(query=search_query, **kwargs) diff --git a/squadai/squads/__init__.py b/squadai/squads/__init__.py new file mode 100644 index 0000000..d988d07 --- /dev/null +++ b/squadai/squads/__init__.py @@ -0,0 +1,3 @@ +from .squad_output import SquadOutput + +__all__ = ["SquadOutput"] diff --git a/squadai/squads/squad_output.py b/squadai/squads/squad_output.py new file mode 100644 index 0000000..82ff6ad --- /dev/null +++ b/squadai/squads/squad_output.py @@ -0,0 +1,49 @@ +import json +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + +from squadai.tasks.output_format import OutputFormat +from squadai.tasks.task_output import TaskOutput +from squadai.types.usage_metrics import UsageMetrics + + +class SquadOutput(BaseModel): + """Class that represents the result of a squad.""" + + raw: str = Field(description="Raw output of squad", default="") + pydantic: Optional[BaseModel] = Field( + description="Pydantic output of Squad", default=None + ) + json_dict: Optional[Dict[str, Any]] = Field( + description="JSON dict output of Squad", default=None + ) + tasks_output: list[TaskOutput] = Field( + description="Output of each task", default=[] + ) + token_usage: UsageMetrics = Field(description="Processed token summary", default={}) + + @property + def json(self) -> Optional[str]: + if self.tasks_output[-1].output_format != OutputFormat.JSON: + raise ValueError( + "No JSON output found in the final task. Please make sure to set the output_json property in the final task in your squad." + ) + + return json.dumps(self.json_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert json_output and pydantic_output to a dictionary.""" + output_dict = {} + if self.json_dict: + output_dict.update(self.json_dict) + elif self.pydantic: + output_dict.update(self.pydantic.model_dump()) + return output_dict + + def __str__(self): + if self.pydantic: + return str(self.pydantic) + if self.json_dict: + return str(self.json_dict) + return self.raw diff --git a/squadai/task.py b/squadai/task.py new file mode 100644 index 0000000..fe2a71e --- /dev/null +++ b/squadai/task.py @@ -0,0 +1,394 @@ +import datetime +import json +import os +import threading +import uuid +from concurrent.futures import Future +from copy import copy +from hashlib import md5 +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +from opentelemetry.trace import Span +from pydantic import ( + UUID4, + BaseModel, + Field, + PrivateAttr, + field_validator, + model_validator, +) +from pydantic_core import PydanticCustomError + +from squadai.agents.agent_builder.base_agent import BaseAgent +from squadai.tasks.output_format import OutputFormat +from squadai.tasks.task_output import TaskOutput +from squadai.utilities.config import process_config +from squadai.utilities.converter import Converter, convert_to_model +from squadai.utilities.i18n import I18N + + +class Task(BaseModel): + """Class that represents a task to be executed. + + Each task must have a description, an expected output and an agent responsible for execution. + + Attributes: + agent: Agent responsible for task execution. Represents entity performing task. + async_execution: Boolean flag indicating asynchronous task execution. + callback: Function/object executed post task completion for additional actions. + config: Dictionary containing task-specific configuration parameters. + context: List of Task instances providing task context or input data. + description: Descriptive text detailing task's purpose and execution. + expected_output: Clear definition of expected task outcome. + output_file: File path for storing task output. + output_json: Pydantic model for structuring JSON output. + output_pydantic: Pydantic model for task output. + tools: List of tools/resources limited for task execution. + """ + + __hash__ = object.__hash__ # type: ignore + used_tools: int = 0 + tools_errors: int = 0 + delegations: int = 0 + i18n: I18N = I18N() + name: Optional[str] = Field(default=None) + prompt_context: Optional[str] = None + description: str = Field(description="Description of the actual task.") + expected_output: str = Field( + description="Clear definition of expected output for the task." + ) + config: Optional[Dict[str, Any]] = Field( + description="Configuration for the agent", + default=None, + ) + callback: Optional[Any] = Field( + description="Callback to be executed after the task is completed.", default=None + ) + agent: Optional[BaseAgent] = Field( + description="Agent responsible for execution the task.", default=None + ) + context: Optional[List["Task"]] = Field( + description="Other tasks that will have their output used as context for this task.", + default=None, + ) + async_execution: Optional[bool] = Field( + description="Whether the task should be executed asynchronously or not.", + default=False, + ) + output_json: Optional[Type[BaseModel]] = Field( + description="A Pydantic model to be used to create a JSON output.", + default=None, + ) + output_pydantic: Optional[Type[BaseModel]] = Field( + description="A Pydantic model to be used to create a Pydantic output.", + default=None, + ) + output_file: Optional[str] = Field( + description="A file path to be used to create a file output.", + default=None, + ) + output: Optional[TaskOutput] = Field( + description="Task output, it's final result after being executed", default=None + ) + tools: Optional[List[Any]] = Field( + default_factory=list, + description="Tools the agent is limited to use for this task.", + ) + id: UUID4 = Field( + default_factory=uuid.uuid4, + frozen=True, + description="Unique identifier for the object, not set by user.", + ) + human_input: Optional[bool] = Field( + description="Whether the task should have a human review the final answer of the agent", + default=False, + ) + converter_cls: Optional[Type[Converter]] = Field( + description="A converter class used to export structured output", + default=None, + ) + + _original_description: Optional[str] = PrivateAttr(default=None) + _original_expected_output: Optional[str] = PrivateAttr(default=None) + _thread: Optional[threading.Thread] = PrivateAttr(default=None) + _execution_time: Optional[float] = PrivateAttr(default=None) + + @model_validator(mode="before") + @classmethod + def process_model_config(cls, values): + return process_config(values, cls) + + @model_validator(mode="after") + def validate_required_fields(self): + required_fields = ["description", "expected_output"] + for field in required_fields: + if getattr(self, field) is None: + raise ValueError( + f"{field} must be provided either directly or through config" + ) + return self + + @field_validator("id", mode="before") + @classmethod + def _deny_user_set_id(cls, v: Optional[UUID4]) -> None: + if v: + raise PydanticCustomError( + "may_not_set_field", "This field is not to be set by the user.", {} + ) + + def _set_start_execution_time(self) -> float: + return datetime.datetime.now().timestamp() + + def _set_end_execution_time(self, start_time: float) -> None: + self._execution_time = datetime.datetime.now().timestamp() - start_time + + @field_validator("output_file") + @classmethod + def output_file_validation(cls, value: str) -> str: + """Validate the output file path by removing the / from the beginning of the path.""" + if value.startswith("/"): + return value[1:] + return value + + @model_validator(mode="after") + def set_attributes_based_on_config(self) -> "Task": + """Set attributes based on the agent configuration.""" + if self.config: + for key, value in self.config.items(): + setattr(self, key, value) + return self + + @model_validator(mode="after") + def check_tools(self): + """Check if the tools are set.""" + if not self.tools and self.agent and self.agent.tools: + self.tools.extend(self.agent.tools) + return self + + @model_validator(mode="after") + def check_output(self): + """Check if an output type is set.""" + output_types = [self.output_json, self.output_pydantic] + if len([type for type in output_types if type]) > 1: + raise PydanticCustomError( + "output_type", + "Only one output type can be set, either output_pydantic or output_json.", + {}, + ) + return self + + def execute_sync( + self, + agent: Optional[BaseAgent] = None, + context: Optional[str] = None, + tools: Optional[List[Any]] = None, + ) -> TaskOutput: + """Execute the task synchronously.""" + return self._execute_core(agent, context, tools) + + @property + def key(self) -> str: + description = self._original_description or self.description + expected_output = self._original_expected_output or self.expected_output + source = [description, expected_output] + + return md5("|".join(source).encode(), usedforsecurity=False).hexdigest() + + def execute_async( + self, + agent: BaseAgent | None = None, + context: Optional[str] = None, + tools: Optional[List[Any]] = None, + ) -> Future[TaskOutput]: + """Execute the task asynchronously.""" + future: Future[TaskOutput] = Future() + threading.Thread( + target=self._execute_task_async, args=(agent, context, tools, future) + ).start() + return future + + def _execute_task_async( + self, + agent: Optional[BaseAgent], + context: Optional[str], + tools: Optional[List[Any]], + future: Future[TaskOutput], + ) -> None: + """Execute the task asynchronously with context handling.""" + result = self._execute_core(agent, context, tools) + future.set_result(result) + + def _execute_core( + self, + agent: Optional[BaseAgent], + context: Optional[str], + tools: Optional[List[Any]], + ) -> TaskOutput: + """Run the core execution logic of the task.""" + agent = agent or self.agent + self.agent = agent + if not agent: + raise Exception( + f"The task '{self.description}' has no agent assigned, therefore it can't be executed directly and should be executed in a Squad using a specific process that support that, like hierarchical." + ) + + start_time = self._set_start_execution_time() + + self.prompt_context = context + tools = tools or self.tools or [] + + result = agent.execute_task( + task=self, + context=context, + tools=tools, + ) + + pydantic_output, json_output = self._export_output(result) + + task_output = TaskOutput( + name=self.name, + description=self.description, + expected_output=self.expected_output, + raw=result, + pydantic=pydantic_output, + json_dict=json_output, + agent=agent.role, + output_format=self._get_output_format(), + ) + self.output = task_output + + self._set_end_execution_time(start_time) + if self.callback: + self.callback(self.output) + + + if self.output_file: + content = ( + json_output + if json_output + else pydantic_output.model_dump_json() + if pydantic_output + else result + ) + self._save_file(content) + + return task_output + + def prompt(self) -> str: + """Prompt the task. + + Returns: + Prompt of the task. + """ + tasks_slices = [self.description] + + output = self.i18n.slice("expected_output").format( + expected_output=self.expected_output + ) + tasks_slices = [self.description, output] + return "\n".join(tasks_slices) + + def interpolate_inputs(self, inputs: Dict[str, Any]) -> None: + """Interpolate inputs into the task description and expected output.""" + if self._original_description is None: + self._original_description = self.description + if self._original_expected_output is None: + self._original_expected_output = self.expected_output + + if inputs: + self.description = self._original_description.format(**inputs) + self.expected_output = self._original_expected_output.format(**inputs) + + def increment_tools_errors(self) -> None: + """Increment the tools errors counter.""" + self.tools_errors += 1 + + def increment_delegations(self) -> None: + """Increment the delegations counter.""" + self.delegations += 1 + + def copy(self, agents: List["BaseAgent"]) -> "Task": + """Create a deep copy of the Task.""" + exclude = { + "id", + "agent", + "context", + "tools", + } + + copied_data = self.model_dump(exclude=exclude) + copied_data = {k: v for k, v in copied_data.items() if v is not None} + + cloned_context = ( + [task.copy(agents) for task in self.context] if self.context else None + ) + + def get_agent_by_role(role: str) -> Union["BaseAgent", None]: + return next((agent for agent in agents if agent.role == role), None) + + cloned_agent = get_agent_by_role(self.agent.role) if self.agent else None + cloned_tools = copy(self.tools) if self.tools else [] + + copied_task = Task( + **copied_data, + context=cloned_context, + agent=cloned_agent, + tools=cloned_tools, + ) + + return copied_task + + def _export_output( + self, result: str + ) -> Tuple[Optional[BaseModel], Optional[Dict[str, Any]]]: + pydantic_output: Optional[BaseModel] = None + json_output: Optional[Dict[str, Any]] = None + + if self.output_pydantic or self.output_json: + model_output = convert_to_model( + result, + self.output_pydantic, + self.output_json, + self.agent, + self.converter_cls, + ) + + if isinstance(model_output, BaseModel): + pydantic_output = model_output + elif isinstance(model_output, dict): + json_output = model_output + elif isinstance(model_output, str): + try: + json_output = json.loads(model_output) + except json.JSONDecodeError: + json_output = None + + return pydantic_output, json_output + + def _get_output_format(self) -> OutputFormat: + if self.output_json: + return OutputFormat.JSON + if self.output_pydantic: + return OutputFormat.PYDANTIC + return OutputFormat.RAW + + def _save_file(self, result: Any) -> None: + if self.output_file is None: + raise ValueError("output_file is not set.") + + directory = os.path.dirname(self.output_file) # type: ignore # Value of type variable "AnyOrLiteralStr" of "dirname" cannot be "str | None" + + if directory and not os.path.exists(directory): + os.makedirs(directory) + + with open(self.output_file, "w", encoding="utf-8") as file: + if isinstance(result, dict): + import json + + json.dump(result, file, ensure_ascii=False, indent=2) + else: + file.write(str(result)) + return None + + def __repr__(self): + return f"Task(description={self.description}, expected_output={self.expected_output})" diff --git a/squadai/tasks/__init__.py b/squadai/tasks/__init__.py new file mode 100644 index 0000000..d1758d0 --- /dev/null +++ b/squadai/tasks/__init__.py @@ -0,0 +1,4 @@ +from squadai.tasks.output_format import OutputFormat +from squadai.tasks.task_output import TaskOutput + +__all__ = ["OutputFormat", "TaskOutput"] diff --git a/squadai/tasks/conditional_task.py b/squadai/tasks/conditional_task.py new file mode 100644 index 0000000..7915cc8 --- /dev/null +++ b/squadai/tasks/conditional_task.py @@ -0,0 +1,47 @@ +from typing import Any, Callable + +from pydantic import Field + +from squadai.task import Task +from squadai.tasks.output_format import OutputFormat +from squadai.tasks.task_output import TaskOutput + + +class ConditionalTask(Task): + """ + A task that can be conditionally executed based on the output of another task. + Note: This cannot be the only task you have in your squad and cannot be the first since its needs context from the previous task. + """ + + condition: Callable[[TaskOutput], bool] = Field( + default=None, + description="Maximum number of retries for an agent to execute a task when an error occurs.", + ) + + def __init__( + self, + condition: Callable[[Any], bool], + **kwargs, + ): + super().__init__(**kwargs) + self.condition = condition + + def should_execute(self, context: TaskOutput) -> bool: + """ + Determines whether the conditional task should be executed based on the provided context. + + Args: + context (Any): The context or output from the previous task that will be evaluated by the condition. + + Returns: + bool: True if the task should be executed, False otherwise. + """ + return self.condition(context) + + def get_skipped_task_output(self): + return TaskOutput( + description=self.description, + raw="", + agent=self.agent.role if self.agent else "", + output_format=OutputFormat.RAW, + ) diff --git a/squadai/tasks/output_format.py b/squadai/tasks/output_format.py new file mode 100644 index 0000000..dbea9ff --- /dev/null +++ b/squadai/tasks/output_format.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class OutputFormat(str, Enum): + """Enum that represents the output format of a task.""" + + JSON = "json" + PYDANTIC = "pydantic" + RAW = "raw" diff --git a/squadai/tasks/task_output.py b/squadai/tasks/task_output.py new file mode 100644 index 0000000..c8fdb07 --- /dev/null +++ b/squadai/tasks/task_output.py @@ -0,0 +1,64 @@ +import json +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field, model_validator + +from squadai.tasks.output_format import OutputFormat + + +class TaskOutput(BaseModel): + """Class that represents the result of a task.""" + + description: str = Field(description="Description of the task") + name: Optional[str] = Field(description="Name of the task", default=None) + expected_output: Optional[str] = Field( + description="Expected output of the task", default=None + ) + summary: Optional[str] = Field(description="Summary of the task", default=None) + raw: str = Field(description="Raw output of the task", default="") + pydantic: Optional[BaseModel] = Field( + description="Pydantic output of task", default=None + ) + json_dict: Optional[Dict[str, Any]] = Field( + description="JSON dictionary of task", default=None + ) + agent: str = Field(description="Agent that executed the task") + output_format: OutputFormat = Field( + description="Output format of the task", default=OutputFormat.RAW + ) + + @model_validator(mode="after") + def set_summary(self): + """Set the summary field based on the description.""" + excerpt = " ".join(self.description.split(" ")[:10]) + self.summary = f"{excerpt}..." + return self + + @property + def json(self) -> Optional[str]: + if self.output_format != OutputFormat.JSON: + raise ValueError( + """ + Invalid output format requested. + If you would like to access the JSON output, + please make sure to set the output_json property for the task + """ + ) + + return json.dumps(self.json_dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert json_output and pydantic_output to a dictionary.""" + output_dict = {} + if self.json_dict: + output_dict.update(self.json_dict) + elif self.pydantic: + output_dict.update(self.pydantic.model_dump()) + return output_dict + + def __str__(self) -> str: + if self.pydantic: + return str(self.pydantic) + if self.json_dict: + return str(self.json_dict) + return self.raw diff --git a/squadai/tools/__init__.py b/squadai/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/tools/agent_tools.py b/squadai/tools/agent_tools.py new file mode 100644 index 0000000..f926f14 --- /dev/null +++ b/squadai/tools/agent_tools.py @@ -0,0 +1,25 @@ +from langchain.tools import StructuredTool + +from squadai.agents.agent_builder.utilities.base_agent_tool import BaseAgentTools + + +class AgentTools(BaseAgentTools): + """Default tools around agent delegation""" + + def tools(self): + coworkers = ", ".join([f"{agent.role}" for agent in self.agents]) + tools = [ + StructuredTool.from_function( + func=self.delegate_work, + name="Delegate work to coworker", + description=self.i18n.tools("delegate_work").format( + coworkers=coworkers + ), + ), + StructuredTool.from_function( + func=self.ask_question, + name="Ask question to coworker", + description=self.i18n.tools("ask_question").format(coworkers=coworkers), + ), + ] + return tools diff --git a/squadai/tools/cache_tools.py b/squadai/tools/cache_tools.py new file mode 100644 index 0000000..c01496f --- /dev/null +++ b/squadai/tools/cache_tools.py @@ -0,0 +1,27 @@ +from langchain.tools import StructuredTool +from pydantic import BaseModel, Field + +from squadai.agents.cache import CacheHandler + + +class CacheTools(BaseModel): + """Default tools to hit the cache.""" + + name: str = "Hit Cache" + cache_handler: CacheHandler = Field( + description="Cache Handler for the squad", + default_factory=CacheHandler, + ) + + def tool(self): + return StructuredTool.from_function( + func=self.hit_cache, + name=self.name, + description="Reads directly from the cache", + ) + + def hit_cache(self, key): + split = key.split("tool:") + tool = split[1].split("|input:")[0].strip() + tool_input = split[1].split("|input:")[1].strip() + return self.cache_handler.read(tool, tool_input) diff --git a/squadai/tools/tool_calling.py b/squadai/tools/tool_calling.py new file mode 100644 index 0000000..2a0fd42 --- /dev/null +++ b/squadai/tools/tool_calling.py @@ -0,0 +1,21 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel as PydanticBaseModel +from pydantic import Field as PydanticField +from pydantic.v1 import BaseModel, Field + + +class ToolCalling(BaseModel): + tool_name: str = Field(..., description="The name of the tool to be called.") + arguments: Optional[Dict[str, Any]] = Field( + ..., description="A dictionary of arguments to be passed to the tool." + ) + + +class InstructorToolCalling(PydanticBaseModel): + tool_name: str = PydanticField( + ..., description="The name of the tool to be called." + ) + arguments: Optional[Dict[str, Any]] = PydanticField( + ..., description="A dictionary of arguments to be passed to the tool." + ) diff --git a/squadai/tools/tool_output_parser.py b/squadai/tools/tool_output_parser.py new file mode 100644 index 0000000..d6c3465 --- /dev/null +++ b/squadai/tools/tool_output_parser.py @@ -0,0 +1,39 @@ +import json +from typing import Any, List + +import regex +from langchain.output_parsers import PydanticOutputParser +from langchain_core.exceptions import OutputParserException +from langchain_core.outputs import Generation +from langchain_core.pydantic_v1 import ValidationError + + +class ToolOutputParser(PydanticOutputParser): + """Parses the function calling of a tool usage and it's arguments.""" + + def parse_result(self, result: List[Generation], *, partial: bool = False) -> Any: + result[0].text = self._transform_in_valid_json(result[0].text) + json_object = super().parse_result(result) + try: + return self.pydantic_object.parse_obj(json_object) + except ValidationError as e: + name = self.pydantic_object.__name__ + msg = f"Failed to parse {name} from completion {json_object}. Got: {e}" + raise OutputParserException(msg, llm_output=json_object) + + def _transform_in_valid_json(self, text) -> str: + text = text.replace("```", "").replace("json", "") + json_pattern = r"\{(?:[^{}]|(?R))*\}" + matches = regex.finditer(json_pattern, text) + + for match in matches: + try: + # Attempt to parse the matched string as JSON + json_obj = json.loads(match.group()) + # Return the first successfully parsed JSON object + json_obj = json.dumps(json_obj) + return str(json_obj) + except json.JSONDecodeError: + # If parsing fails, skip to the next match + continue + return text diff --git a/squadai/tools/tool_usage.py b/squadai/tools/tool_usage.py new file mode 100644 index 0000000..874fd9b --- /dev/null +++ b/squadai/tools/tool_usage.py @@ -0,0 +1,405 @@ +import ast +import os +from difflib import SequenceMatcher +from textwrap import dedent +from typing import Any, List, Union + +from langchain_core.tools import BaseTool +from langchain_groq import ChatGroq +from dotenv import load_dotenv + +from squadai.agents.tools_handler import ToolsHandler +from squadai.tools.tool_calling import InstructorToolCalling, ToolCalling +from squadai.utilities import I18N, Converter, ConverterError, Printer + +load_dotenv() + +GROQ_BIGGER_MODELS = [os.getenv("GROQ_MODEL_NAME")] + + +class ToolUsageErrorException(Exception): + """Exception raised for errors in the tool usage.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(self.message) + + +class ToolUsage: + """ + Class that represents the usage of a tool by an agent. + + Attributes: + task: Task being executed. + tools_handler: Tools handler that will manage the tool usage. + tools: List of tools available for the agent. + original_tools: Original tools available for the agent before being converted to BaseTool. + tools_description: Description of the tools available for the agent. + tools_names: Names of the tools available for the agent. + function_calling_llm: Language model to be used for the tool usage. + """ + + def __init__( + self, + tools_handler: ToolsHandler, + tools: List[BaseTool], + original_tools: List[Any], + tools_description: str, + tools_names: str, + task: Any, + function_calling_llm: Any, + agent: Any, + action: Any, + ) -> None: + self._i18n: I18N = I18N() + self._printer: Printer = Printer() + self._run_attempts: int = 1 + self._max_parsing_attempts: int = 3 + self._remember_format_after_usages: int = 3 + self.agent = agent + self.tools_description = tools_description + self.tools_names = tools_names + self.tools_handler = tools_handler + self.original_tools = original_tools + self.tools = tools + self.task = task + self.action = action + self.function_calling_llm = function_calling_llm + + + if isinstance(self.function_calling_llm, ChatGroq): + if " " in self.tools_names: + raise Exception( + "Tools names should not have spaces for ChatGroq models." + ) + + # Set the maximum parsing attempts for bigger models + if (isinstance(self.function_calling_llm, ChatGroq)) and ( + self.function_calling_llm.groq_api_base is None + ): + if self.function_calling_llm.model_name in GROQ_BIGGER_MODELS: + self._max_parsing_attempts = 2 + self._remember_format_after_usages = 4 + + def parse(self, tool_string: str): + """Parse the tool string and return the tool calling.""" + return self._tool_calling(tool_string) + + def use( + self, calling: Union[ToolCalling, InstructorToolCalling], tool_string: str + ) -> str: + if isinstance(calling, ToolUsageErrorException): + error = calling.message + if self.agent.verbose: + self._printer.print(content=f"\n\n{error}\n", color="red") + self.task.increment_tools_errors() + return error + + # BUG? The code below seems to be unreachable + try: + tool = self._select_tool(calling.tool_name) + except Exception as e: + error = getattr(e, "message", str(e)) + self.task.increment_tools_errors() + if self.agent.verbose: + self._printer.print(content=f"\n\n{error}\n", color="red") + return error + return f"{self._use(tool_string=tool_string, tool=tool, calling=calling)}" # type: ignore # BUG?: "_use" of "ToolUsage" does not return a value (it only ever returns None) + + def _use( + self, + tool_string: str, + tool: BaseTool, + calling: Union[ToolCalling, InstructorToolCalling], + ) -> str: # TODO: Fix this return type + + if self._check_tool_repeated_usage(calling=calling): # type: ignore # _check_tool_repeated_usage of "ToolUsage" does not return a value (it only ever returns None) + try: + result = self._i18n.errors("task_repeated_usage").format( + tool_names=self.tools_names + ) + if self.agent.verbose: + self._printer.print(content=f"\n\n{result}\n", color="purple") + self._telemetry.tool_repeated_usage( + llm=self.function_calling_llm, + tool_name=tool.name, + attempts=self._run_attempts, + ) + result = self._format_result(result=result) # type: ignore # "_format_result" of "ToolUsage" does not return a value (it only ever returns None) + return result # type: ignore # Fix the return type of this function + + except Exception: + self.task.increment_tools_errors() + + result = None # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str") + + if self.tools_handler.cache: + result = self.tools_handler.cache.read( # type: ignore # Incompatible types in assignment (expression has type "str | None", variable has type "str") + tool=calling.tool_name, input=calling.arguments + ) + + original_tool = next( + (ot for ot in self.original_tools if ot.name == tool.name), None + ) + + if result is None: #! finecwg: if not result --> if result is None + try: + if calling.tool_name in [ + "Delegate work to coworker", + "Ask question to coworker", + ]: + self.task.increment_delegations() + + if calling.arguments: + try: + acceptable_args = tool.args_schema.schema()["properties"].keys() # type: ignore # Item "None" of "type[BaseModel] | None" has no attribute "schema" + arguments = { + k: v + for k, v in calling.arguments.items() + if k in acceptable_args + } + result = tool.invoke(input=arguments) + except Exception: + arguments = calling.arguments + result = tool.invoke(input=arguments) + else: + result = tool.invoke(input={}) + except Exception as e: + self._run_attempts += 1 + if self._run_attempts > self._max_parsing_attempts: + error_message = self._i18n.errors("tool_usage_exception").format( + error=e, tool=tool.name, tool_inputs=tool.description + ) + error = ToolUsageErrorException( + f'\n{error_message}.\nMoving on then. {self._i18n.slice("format").format(tool_names=self.tools_names)}' + ).message + self.task.increment_tools_errors() + if self.agent.verbose: + self._printer.print( + content=f"\n\n{error_message}\n", color="red" + ) + return error # type: ignore # No return value expected + + self.task.increment_tools_errors() + return self.use(calling=calling, tool_string=tool_string) # type: ignore # No return value expected + + if self.tools_handler: + should_cache = True + if ( + hasattr(original_tool, "cache_function") + and original_tool.cache_function # type: ignore # Item "None" of "Any | None" has no attribute "cache_function" + ): + should_cache = original_tool.cache_function( # type: ignore # Item "None" of "Any | None" has no attribute "cache_function" + calling.arguments, result + ) + + self.tools_handler.on_tool_use( + calling=calling, output=result, should_cache=should_cache + ) + + if self.agent.verbose: + self._printer.print(content=f"\n\n{result}\n", color="purple") + result = self._format_result(result=result) # type: ignore # "_format_result" of "ToolUsage" does not return a value (it only ever returns None) + data = { + "result": result, + "tool_name": tool.name, + "tool_args": calling.arguments, + } + + if ( + hasattr(original_tool, "result_as_answer") + and original_tool.result_as_answer # type: ignore # Item "None" of "Any | None" has no attribute "cache_function" + ): + result_as_answer = original_tool.result_as_answer # type: ignore # Item "None" of "Any | None" has no attribute "result_as_answer" + data["result_as_answer"] = result_as_answer + + self.agent.tools_results.append(data) + + return result # type: ignore # No return value expected + + def _format_result(self, result: Any) -> None: + self.task.used_tools += 1 + if self._should_remember_format(): # type: ignore # "_should_remember_format" of "ToolUsage" does not return a value (it only ever returns None) + result = self._remember_format(result=result) # type: ignore # "_remember_format" of "ToolUsage" does not return a value (it only ever returns None) + return result + + def _should_remember_format(self) -> None: + return self.task.used_tools % self._remember_format_after_usages == 0 + + def _remember_format(self, result: str) -> None: + result = str(result) + result += "\n\n" + self._i18n.slice("tools").format( + tools=self.tools_description, tool_names=self.tools_names + ) + return result # type: ignore # No return value expected + + def _check_tool_repeated_usage( + self, calling: Union[ToolCalling, InstructorToolCalling] + ) -> None: + if not self.tools_handler: + return False # type: ignore # No return value expected + if last_tool_usage := self.tools_handler.last_used_tool: + return (calling.tool_name == last_tool_usage.tool_name) and ( # type: ignore # No return value expected + calling.arguments == last_tool_usage.arguments + ) + + def _select_tool(self, tool_name: str) -> BaseTool: + order_tools = sorted( + self.tools, + key=lambda tool: SequenceMatcher( + None, tool.name.lower().strip(), tool_name.lower().strip() + ).ratio(), + reverse=True, + ) + for tool in order_tools: + if ( + tool.name.lower().strip() == tool_name.lower().strip() + or SequenceMatcher( + None, tool.name.lower().strip(), tool_name.lower().strip() + ).ratio() + > 0.85 + ): + return tool + self.task.increment_tools_errors() + if tool_name and tool_name != "": + raise Exception( + f"Action '{tool_name}' don't exist, these are the only available Actions:\n {self.tools_description}" + ) + else: + raise Exception( + f"I forgot the Action name, these are the only available Actions: {self.tools_description}" + ) + + def _render(self) -> str: + """Render the tool name and description in plain text.""" + descriptions = [] + for tool in self.tools: + args = { + k: {k2: v2 for k2, v2 in v.items() if k2 in ["description", "type"]} + for k, v in tool.args.items() + } + descriptions.append( + "\n".join( + [ + f"Tool Name: {tool.name.lower()}", + f"Tool Description: {tool.description}", + f"Tool Arguments: {args}", + ] + ) + ) + return "\n--\n".join(descriptions) + + def _is_llama(self, llm) -> bool: + return isinstance(llm, ChatGroq) and llm.groq_api_base is None + + def _tool_calling( + self, tool_string: str + ) -> Union[ToolCalling, InstructorToolCalling]: + try: + if self.function_calling_llm: + model = ( + InstructorToolCalling + if self._is_llama(self.function_calling_llm) + else ToolCalling + ) + converter = Converter( + text=f"Only tools available:\n###\n{self._render()}\n\nReturn a valid schema for the tool, the tool name must be exactly equal one of the options, use this text to inform the valid output schema:\n\n{tool_string}```", + llm=self.function_calling_llm, + model=model, + instructions=dedent( + """\ + The schema should have the following structure, only two keys: + - tool_name: str + - arguments: dict (with all arguments being passed) + + Example: + {"tool_name": "tool name", "arguments": {"arg_name1": "value", "arg_name2": 2}}""", + ), + max_attempts=1, + ) + calling = converter.to_pydantic() + + if isinstance(calling, ConverterError): + raise calling + else: + tool_name = self.action.tool + tool = self._select_tool(tool_name) + try: + tool_input = self._validate_tool_input(self.action.tool_input) + arguments = ast.literal_eval(tool_input) + except Exception: + return ToolUsageErrorException( # type: ignore # Incompatible return value type (got "ToolUsageErrorException", expected "ToolCalling | InstructorToolCalling") + f'{self._i18n.errors("tool_arguments_error")}' + ) + if not isinstance(arguments, dict): + return ToolUsageErrorException( # type: ignore # Incompatible return value type (got "ToolUsageErrorException", expected "ToolCalling | InstructorToolCalling") + f'{self._i18n.errors("tool_arguments_error")}' + ) + calling = ToolCalling( # type: ignore # Unexpected keyword argument "log" for "ToolCalling" + tool_name=tool.name, + arguments=arguments, + log=tool_string, + ) + except Exception as e: + self._run_attempts += 1 + if self._run_attempts > self._max_parsing_attempts: + self.task.increment_tools_errors() + if self.agent.verbose: + self._printer.print(content=f"\n\n{e}\n", color="red") + return ToolUsageErrorException( # type: ignore # Incompatible return value type (got "ToolUsageErrorException", expected "ToolCalling | InstructorToolCalling") + f'{self._i18n.errors("tool_usage_error").format(error=e)}\nMoving on then. {self._i18n.slice("format").format(tool_names=self.tools_names)}' + ) + return self._tool_calling(tool_string) + + return calling + + def _validate_tool_input(self, tool_input: str) -> str: + try: + ast.literal_eval(tool_input) + return tool_input + except Exception: + # Clean and ensure the string is properly enclosed in braces + tool_input = tool_input.strip() + if not tool_input.startswith("{"): + tool_input = "{" + tool_input + if not tool_input.endswith("}"): + tool_input += "}" + + # Manually split the input into key-value pairs + entries = tool_input.strip("{} ").split(",") + formatted_entries = [] + + for entry in entries: + if ":" not in entry: + continue # Skip malformed entries + key, value = entry.split(":", 1) + + # Remove extraneous white spaces and quotes, replace single quotes + key = key.strip().strip('"').replace("'", '"') + value = value.strip() + + # Handle replacement of single quotes at the start and end of the value string + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] # Remove single quotes + value = ( + '"' + value.replace('"', '\\"') + '"' + ) # Re-encapsulate with double quotes + elif value.isdigit(): # Check if value is a digit, hence integer + formatted_value = value + elif value.lower() in [ + "true", + "false", + "null", + ]: # Check for boolean and null values + formatted_value = value.lower() + else: + # Assume the value is a string and needs quotes + formatted_value = '"' + value.replace('"', '\\"') + '"' + + # Rebuild the entry with proper quoting + formatted_entry = f'"{key}": {formatted_value}' + formatted_entries.append(formatted_entry) + + # Reconstruct the JSON string + new_json_string = "{" + ", ".join(formatted_entries) + "}" + return new_json_string diff --git a/squadai/translations/en.json b/squadai/translations/en.json new file mode 100644 index 0000000..70e50c2 --- /dev/null +++ b/squadai/translations/en.json @@ -0,0 +1,35 @@ +{ + "hierarchical_manager_agent": { + "role": "Squad Manager", + "goal": "Manage the team to complete the task in the best way possible.", + "backstory": "You are a seasoned manager with a knack for getting the best out of your team.\nYou are also known for your ability to delegate work to the right people, and to ask the right questions to get the best out of your team.\nEven though you don't perform tasks by yourself, you have a lot of experience in the field, which allows you to properly evaluate the work of your team members." + }, + "slices": { + "observation": "\nObservation", + "task": "\nCurrent Task: {input}\n\nBegin! This is VERY important to you, use the tools available and give your best Final Answer, your job depends on it!\n\nThought:", + "memory": "\n\n# Useful context: \n{memory}", + "role_playing": "You are {role}. {backstory}\nYour personal goal is: {goal}", + "tools": "\nYou ONLY have access to the following tools, and should NEVER make up tools that are not listed here:\n\n{tools}\n\nUse the following format:\n\nThought: you should always think about what to do\nAction: the action to take, only one name of [{tool_names}], just the name, exactly as it's written.\nAction Input: the input to the action, just a simple python dictionary, enclosed in curly braces, using \" to wrap keys and values.\nObservation: the result of the action\n\nOnce all necessary information is gathered:\n\nThought: I now know the final answer\nFinal Answer: the final answer to the original input question\n", + "no_tools": "To give my best complete final answer to the task use the exact following format:\n\nThought: I now can give a great answer\nFinal Answer: my best complete final answer to the task.\nYour final answer must be the great and the most complete as possible, it must be outcome described.\n\nI MUST use these formats, my job depends on it!", + "format": "I MUST either use a tool (use one at time) OR give my best final answer. To Use the following format:\n\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action, dictionary enclosed in curly braces\nObservation: the result of the action\n... (this Thought/Action/Action Input/Observation can repeat N times)\nThought: I now can give a great answer\nFinal Answer: my best complete final answer to the task.\nYour final answer must be the great and the most complete as possible, it must be outcome described\n\n ", + "final_answer_format": "If you don't need to use any more tools, you must give your best complete final answer, make sure it satisfy the expect criteria, use the EXACT format below:\n\nThought: I now can give a great answer\nFinal Answer: my best complete final answer to the task.\n\n", + "format_without_tools": "\nSorry, I didn't use the right format. I MUST either use a tool (among the available ones), OR give my best final answer.\nI just remembered the expected format I must follow:\n\nQuestion: the input question you must answer\nThought: you should always think about what to do\nAction: the action to take, should be one of [{tool_names}]\nAction Input: the input to the action\nObservation: the result of the action\n... (this Thought/Action/Action Input/Observation can repeat N times)\nThought: I now can give a great answer\nFinal Answer: my best complete final answer to the task\nYour final answer must be the great and the most complete as possible, it must be outcome described\n\n", + "task_with_context": "{task}\n\nThis is the context you're working with:\n{context}", + "expected_output": "\nThis is the expect criteria for your final answer: {expected_output} \n you MUST return the actual complete content as the final answer, not a summary.", + "human_feedback": "You got human feedback on your work, re-evaluate it and give a new Final Answer when ready.\n {human_feedback}", + "getting_input": "This is the agent's final answer: {final_answer}\nPlease provide feedback: " + }, + "errors": { + "force_final_answer": "Tool won't be use because it's time to give your final answer. Don't use tools and just your absolute BEST Final answer.", + "agent_tool_unexsiting_coworker": "\nError executing tool. coworker mentioned not found, it must be one of the following options:\n{coworkers}\n", + "task_repeated_usage": "I tried reusing the same input, I must stop using this action input. I'll try something else instead.\n\n", + "tool_usage_error": "I encountered an error: {error}", + "tool_arguments_error": "Error: the Action Input is not a valid key, value dictionary.", + "wrong_tool_name": "You tried to use the tool {tool}, but it doesn't exist. You must use one of the following tools, use one at time: {tools}.", + "tool_usage_exception": "I encountered an error while trying to use the tool. This was the error: {error}.\n Tool {tool} accepts these inputs: {tool_inputs}" + }, + "tools": { + "delegate_work": "Delegate a specific task to one of the following coworkers: {coworkers}\nThe input to this tool should be the coworker, the task you want them to do, and ALL necessary context to execute the task, they know nothing about the task, so share absolute everything you know, don't reference things but instead explain them.", + "ask_question": "Ask a specific question to one of the following coworkers: {coworkers}\nThe input to this tool should be the coworker, the question you have for them, and ALL necessary context to ask the question properly, they know nothing about the question, so share absolute everything you know, don't reference things but instead explain them." + } +} diff --git a/squadai/types/__init__.py b/squadai/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squadai/types/usage_metrics.py b/squadai/types/usage_metrics.py new file mode 100644 index 0000000..a5ce60f --- /dev/null +++ b/squadai/types/usage_metrics.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, Field + + +class UsageMetrics(BaseModel): + """ + Model to track usage metrics for the squad's execution. + + Attributes: + total_tokens: Total number of tokens used. + prompt_tokens: Number of tokens used in prompts. + completion_tokens: Number of tokens used in completions. + successful_requests: Number of successful requests made. + """ + + total_tokens: int = Field(default=0, description="Total number of tokens used.") + prompt_tokens: int = Field( + default=0, description="Number of tokens used in prompts." + ) + completion_tokens: int = Field( + default=0, description="Number of tokens used in completions." + ) + successful_requests: int = Field( + default=0, description="Number of successful requests made." + ) + + def add_usage_metrics(self, usage_metrics: "UsageMetrics"): + """ + Add the usage metrics from another UsageMetrics object. + + Args: + usage_metrics (UsageMetrics): The usage metrics to add. + """ + self.total_tokens += usage_metrics.total_tokens + self.prompt_tokens += usage_metrics.prompt_tokens + self.completion_tokens += usage_metrics.completion_tokens + self.successful_requests += usage_metrics.successful_requests diff --git a/squadai/utilities/__init__.py b/squadai/utilities/__init__.py new file mode 100644 index 0000000..f13fce3 --- /dev/null +++ b/squadai/utilities/__init__.py @@ -0,0 +1,26 @@ +from .converter import Converter, ConverterError +from .file_handler import FileHandler +from .i18n import I18N +from .instructor import Instructor +from .logger import Logger +from .parser import YamlParser +from .printer import Printer +from .prompts import Prompts +from .rpm_controller import RPMController +from .exceptions.context_window_exceeding_exception import ( + LLMContextLengthExceededException, +) + +__all__ = [ + "Converter", + "ConverterError", + "FileHandler", + "I18N", + "Instructor", + "Logger", + "Printer", + "Prompts", + "RPMController", + "YamlParser", + "LLMContextLengthExceededException", +] diff --git a/squadai/utilities/config.py b/squadai/utilities/config.py new file mode 100644 index 0000000..56a59ce --- /dev/null +++ b/squadai/utilities/config.py @@ -0,0 +1,40 @@ +from typing import Any, Dict, Type + +from pydantic import BaseModel + + +def process_config( + values: Dict[str, Any], model_class: Type[BaseModel] +) -> Dict[str, Any]: + """ + Process the config dictionary and update the values accordingly. + + Args: + values (Dict[str, Any]): The dictionary of values to update. + model_class (Type[BaseModel]): The Pydantic model class to reference for field validation. + + Returns: + Dict[str, Any]: The updated values dictionary. + """ + config = values.get("config", {}) + if not config: + return values + + # Copy values from config (originally from YAML) to the model's attributes. + # Only copy if the attribute isn't already set, preserving any explicitly defined values. + for key, value in config.items(): + if key not in model_class.model_fields: + continue + if values.get(key) is not None: + continue + if isinstance(value, (str, int, float, bool, list)): + values[key] = value + elif isinstance(value, dict): + if isinstance(values.get(key), dict): + values[key].update(value) + else: + values[key] = value + + # Remove the config from values to avoid duplicate processing + values.pop("config", None) + return values diff --git a/squadai/utilities/constants.py b/squadai/utilities/constants.py new file mode 100644 index 0000000..22cc2ff --- /dev/null +++ b/squadai/utilities/constants.py @@ -0,0 +1,2 @@ +TRAINING_DATA_FILE = "training_data.pkl" +TRAINED_AGENTS_DATA_FILE = "trained_agents_data.pkl" diff --git a/squadai/utilities/converter.py b/squadai/utilities/converter.py new file mode 100644 index 0000000..dba5e87 --- /dev/null +++ b/squadai/utilities/converter.py @@ -0,0 +1,229 @@ +import json +import re +from typing import Any, Optional, Type, Union + +from langchain.schema import HumanMessage, SystemMessage +from langchain_groq import ChatGroq +from pydantic import BaseModel, ValidationError + +from squadai.agents.agent_builder.utilities.base_output_converter import OutputConverter +from squadai.utilities.printer import Printer +from squadai.utilities.pydantic_schema_parser import PydanticSchemaParser + + +class ConverterError(Exception): + """Error raised when Converter fails to parse the input.""" + + def __init__(self, message: str, *args: object) -> None: + super().__init__(message, *args) + self.message = message + + +class Converter(OutputConverter): + """Class that converts text into either pydantic or json.""" + + def to_pydantic(self, current_attempt=1): + """Convert text to pydantic.""" + try: + if self.is_llama: + return self._create_instructor().to_pydantic() + else: + return self._create_chain().invoke({}) + except Exception as e: + if current_attempt < self.max_attempts: + return self.to_pydantic(current_attempt + 1) + return ConverterError( + f"Failed to convert text into a pydantic model due to the following error: {e}" + ) + + def to_json(self, current_attempt=1): + """Convert text to json.""" + try: + if self.is_llama: + return self._create_instructor().to_json() + else: + return json.dumps(self._create_chain().invoke({}).model_dump()) + except Exception as e: + if current_attempt < self.max_attempts: + return self.to_json(current_attempt + 1) + return ConverterError(f"Failed to convert text into JSON, error: {e}.") + + def _create_instructor(self): + """Create an instructor.""" + from squadai.utilities import Instructor + + inst = Instructor( + llm=self.llm, + max_attempts=self.max_attempts, + model=self.model, + content=self.text, + instructions=self.instructions, + ) + return inst + + def _create_chain(self): + """Create a chain.""" + from squadai.utilities.squad_pydantic_output_parser import ( + SquadPydanticOutputParser, + ) + + parser = SquadPydanticOutputParser(pydantic_object=self.model) + new_prompt = SystemMessage(content=self.instructions) + HumanMessage( + content=self.text + ) + return new_prompt | self.llm | parser + + @property + def is_llama(self) -> bool: + """Return if llm provided is of llama from groq.""" + return isinstance(self.llm, ChatGroq) and self.llm.groq_api_base is None + + +def convert_to_model( + result: str, + output_pydantic: Optional[Type[BaseModel]], + output_json: Optional[Type[BaseModel]], + agent: Any, + converter_cls: Optional[Type[Converter]] = None, +) -> Union[dict, BaseModel, str]: + model = output_pydantic or output_json + if model is None: + return result + + try: + escaped_result = json.dumps(json.loads(result, strict=False)) + return validate_model(escaped_result, model, bool(output_json)) + except json.JSONDecodeError as e: + Printer().print( + content=f"Error parsing JSON: {e}. Attempting to handle partial JSON.", + color="yellow", + ) + return handle_partial_json( + result, model, bool(output_json), agent, converter_cls + ) + except ValidationError as e: + Printer().print( + content=f"Pydantic validation error: {e}. Attempting to handle partial JSON.", + color="yellow", + ) + return handle_partial_json( + result, model, bool(output_json), agent, converter_cls + ) + except Exception as e: + Printer().print( + content=f"Unexpected error during model conversion: {type(e).__name__}: {e}. Returning original result.", + color="red", + ) + return result + + +def validate_model( + result: str, model: Type[BaseModel], is_json_output: bool +) -> Union[dict, BaseModel]: + exported_result = model.model_validate_json(result) + if is_json_output: + return exported_result.model_dump() + return exported_result + + +def handle_partial_json( + result: str, + model: Type[BaseModel], + is_json_output: bool, + agent: Any, + converter_cls: Optional[Type[Converter]] = None, +) -> Union[dict, BaseModel, str]: + match = re.search(r"({.*})", result, re.DOTALL) + if match: + try: + exported_result = model.model_validate_json(match.group(0)) + if is_json_output: + return exported_result.model_dump() + return exported_result + except json.JSONDecodeError as e: + Printer().print( + content=f"Error parsing JSON: {e}. The extracted JSON-like string is not valid JSON. Attempting alternative conversion method.", + color="yellow", + ) + except ValidationError as e: + Printer().print( + content=f"Pydantic validation error: {e}. The JSON structure doesn't match the expected model. Attempting alternative conversion method.", + color="yellow", + ) + except Exception as e: + Printer().print( + content=f"Unexpected error during partial JSON handling: {type(e).__name__}: {e}. Attempting alternative conversion method.", + color="red", + ) + + return convert_with_instructions( + result, model, is_json_output, agent, converter_cls + ) + + +def convert_with_instructions( + result: str, + model: Type[BaseModel], + is_json_output: bool, + agent: Any, + converter_cls: Optional[Type[Converter]] = None, +) -> Union[dict, BaseModel, str]: + llm = agent.function_calling_llm or agent.llm + instructions = get_conversion_instructions(model, llm) + + converter = create_converter( + agent=agent, + converter_cls=converter_cls, + llm=llm, + text=result, + model=model, + instructions=instructions, + ) + exported_result = ( + converter.to_pydantic() if not is_json_output else converter.to_json() + ) + + if isinstance(exported_result, ConverterError): + Printer().print( + content=f"{exported_result.message} Using raw output instead.", + color="red", + ) + return result + + return exported_result + + +def get_conversion_instructions(model: Type[BaseModel], llm: Any) -> str: + instructions = "I'm gonna convert this raw text into valid JSON." + if not is_llama(llm): + model_schema = PydanticSchemaParser(model=model).get_schema() + instructions = f"{instructions}\n\nThe json should have the following structure, with the following keys:\n{model_schema}" + return instructions + + +def is_llama(llm: Any) -> bool: + from langchain_groq import ChatGroq + + return isinstance(llm, ChatGroq) and llm.groq_api_base is None + + +def create_converter( + agent: Optional[Any] = None, + converter_cls: Optional[Type[Converter]] = None, + *args, + **kwargs, +) -> Converter: + if agent and not converter_cls: + if hasattr(agent, "get_output_converter"): + converter = agent.get_output_converter(*args, **kwargs) + else: + raise AttributeError("Agent does not have a 'get_output_converter' method") + elif converter_cls: + converter = converter_cls(*args, **kwargs) + else: + raise ValueError("Either agent or converter_cls must be provided") + + if not converter: + raise Exception("No output converter found or set.") + + return converter diff --git a/squadai/utilities/evaluators/squad_evaluator_handler.py b/squadai/utilities/evaluators/squad_evaluator_handler.py new file mode 100644 index 0000000..72e98bc --- /dev/null +++ b/squadai/utilities/evaluators/squad_evaluator_handler.py @@ -0,0 +1,163 @@ +from collections import defaultdict + +from langchain_groq import ChatGroq +from pydantic import BaseModel, Field +from rich.console import Console +from rich.table import Table + +from squadai.agent import Agent +from squadai.task import Task +from squadai.tasks.task_output import TaskOutput + + +class TaskEvaluationPydanticOutput(BaseModel): + quality: float = Field( + description="A score from 1 to 10 evaluating on completion, quality, and overall performance from the task_description and task_expected_output to the actual Task Output." + ) + + +class SquadEvaluator: + """ + A class to evaluate the performance of the agents in the squad based on the tasks they have performed. + + Attributes: + squad (Squad): The squad of agents to evaluate. + groq_model_name (str): The model to use for evaluating the performance of the agents (for now ONLY Groq accepted). + tasks_scores (defaultdict): A dictionary to store the scores of the agents for each task. + iteration (int): The current iteration of the evaluation. + """ + + tasks_scores: defaultdict = defaultdict(list) + run_execution_times: defaultdict = defaultdict(list) + iteration: int = 0 + + def __init__(self, squad, openai_model_name: str): + self.squad = squad + self.groq_model_name = groq_model_name + self._setup_for_evaluating() + + def _setup_for_evaluating(self) -> None: + """Sets up the squad for evaluating.""" + for task in self.squad.tasks: + task.callback = self.evaluate + + def _evaluator_agent(self): + return Agent( + role="Task Execution Evaluator", + goal=( + "Your goal is to evaluate the performance of the agents in the squad based on the tasks they have performed using score from 1 to 10 evaluating on completion, quality, and overall performance." + ), + backstory="Evaluator agent for squad evaluation with precise capabilities to evaluate the performance of the agents in the squad based on the tasks they have performed", + verbose=False, + llm=ChatGroq(model=self.groq_model_name), + ) + + def _evaluation_task( + self, evaluator_agent: Agent, task_to_evaluate: Task, task_output: str + ) -> Task: + return Task( + description=( + "Based on the task description and the expected output, compare and evaluate the performance of the agents in the squad based on the Task Output they have performed using score from 1 to 10 evaluating on completion, quality, and overall performance." + f"task_description: {task_to_evaluate.description} " + f"task_expected_output: {task_to_evaluate.expected_output} " + f"agent: {task_to_evaluate.agent.role if task_to_evaluate.agent else None} " + f"agent_goal: {task_to_evaluate.agent.goal if task_to_evaluate.agent else None} " + f"Task Output: {task_output}" + ), + expected_output="Evaluation Score from 1 to 10 based on the performance of the agents on the tasks", + agent=evaluator_agent, + output_pydantic=TaskEvaluationPydanticOutput, + ) + + def set_iteration(self, iteration: int) -> None: + self.iteration = iteration + + def print_squad_evaluation_result(self) -> None: + """ + Prints the evaluation result of the squad in a table. + A Squad with 2 tasks using the command squadai test -n 2 + will output the following table: + + Task Scores + (1-10 Higher is better) + ┏━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━┓ + ┃ Tasks/Squad ┃ Run 1 ┃ Run 2 ┃ Avg. Total ┃ + ┡━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━┩ + │ Task 1 │ 10.0 │ 9.0 │ 9.5 │ + │ Task 2 │ 9.0 │ 9.0 │ 9.0 │ + │ Squad │ 9.5 │ 9.0 │ 9.2 │ + └────────────┴───────┴───────┴────────────┘ + """ + task_averages = [ + sum(scores) / len(scores) for scores in zip(*self.tasks_scores.values()) + ] + squad_average = sum(task_averages) / len(task_averages) + + # Create a table + table = Table(title="Tasks Scores \n (1-10 Higher is better)") + + # Add columns for the table + table.add_column("Tasks/Squad") + for run in range(1, len(self.tasks_scores) + 1): + table.add_column(f"Run {run}") + table.add_column("Avg. Total") + + # Add rows for each task + for task_index in range(len(task_averages)): + task_scores = [ + self.tasks_scores[run][task_index] + for run in range(1, len(self.tasks_scores) + 1) + ] + avg_score = task_averages[task_index] + table.add_row( + f"Task {task_index + 1}", *map(str, task_scores), f"{avg_score:.1f}" + ) + + # Add a row for the squad average + squad_scores = [ + sum(self.tasks_scores[run]) / len(self.tasks_scores[run]) + for run in range(1, len(self.tasks_scores) + 1) + ] + table.add_row("Squad", *map(str, squad_scores), f"{squad_average:.1f}") + + run_exec_times = [ + int(sum(tasks_exec_times)) + for _, tasks_exec_times in self.run_execution_times.items() + ] + execution_time_avg = int(sum(run_exec_times) / len(run_exec_times)) + table.add_row( + "Execution Time (s)", + *map(str, run_exec_times), + f"{execution_time_avg}", + ) + # Display the table in the terminal + console = Console() + console.print(table) + + def evaluate(self, task_output: TaskOutput): + """Evaluates the performance of the agents in the squad based on the tasks they have performed.""" + current_task = None + for task in self.squad.tasks: + if task.description == task_output.description: + current_task = task + break + + if not current_task or not task_output: + raise ValueError( + "Task to evaluate and task output are required for evaluation" + ) + + evaluator_agent = self._evaluator_agent() + evaluation_task = self._evaluation_task( + evaluator_agent, current_task, task_output.raw + ) + + evaluation_result = evaluation_task.execute_sync() + + if isinstance(evaluation_result.pydantic, TaskEvaluationPydanticOutput): + self.tasks_scores[self.iteration].append(evaluation_result.pydantic.quality) + self.run_execution_times[self.iteration].append( + current_task._execution_time + ) + else: + raise ValueError("Evaluation result is not in the expected format") diff --git a/squadai/utilities/evaluators/task_evaluator.py b/squadai/utilities/evaluators/task_evaluator.py new file mode 100644 index 0000000..52692bb --- /dev/null +++ b/squadai/utilities/evaluators/task_evaluator.py @@ -0,0 +1,133 @@ +import os +from typing import List + +from langchain_groq import ChatGroq +from pydantic import BaseModel, Field + +from squadai.utilities import Converter +from squadai.utilities.pydantic_schema_parser import PydanticSchemaParser + + +def mock_agent_ops_provider(): + def track_agent(*args, **kwargs): + def noop(f): + return f + + return noop + + return track_agent + +track_agent = mock_agent_ops_provider() + + +class Entity(BaseModel): + name: str = Field(description="The name of the entity.") + type: str = Field(description="The type of the entity.") + description: str = Field(description="Description of the entity.") + relationships: List[str] = Field(description="Relationships of the entity.") + + +class TaskEvaluation(BaseModel): + suggestions: List[str] = Field( + description="Suggestions to improve future similar tasks." + ) + quality: float = Field( + description="A score from 0 to 10 evaluating on completion, quality, and overall performance, all taking into account the task description, expected output, and the result of the task." + ) + entities: List[Entity] = Field( + description="Entities extracted from the task output." + ) + + +class TrainingTaskEvaluation(BaseModel): + suggestions: List[str] = Field( + description="Based on the Human Feedbacks and the comparison between Initial Outputs and Improved outputs provide action items based on human_feedback for future tasks." + ) + quality: float = Field( + description="A score from 0 to 10 evaluating on completion, quality, and overall performance from the improved output to the initial output based on the human feedback." + ) + final_summary: str = Field( + description="A step by step action items to improve the next Agent based on the human-feedback and improved output." + ) + + +@track_agent(name="Task Evaluator") +class TaskEvaluator: + def __init__(self, original_agent): + self.llm = original_agent.llm + + def evaluate(self, task, output) -> TaskEvaluation: + evaluation_query = ( + f"Assess the quality of the task completed based on the description, expected output, and actual results.\n\n" + f"Task Description:\n{task.description}\n\n" + f"Expected Output:\n{task.expected_output}\n\n" + f"Actual Output:\n{output}\n\n" + "Please provide:\n" + "- Bullet points suggestions to improve future similar tasks\n" + "- A score from 0 to 10 evaluating on completion, quality, and overall performance" + "- Entities extracted from the task output, if any, their type, description, and relationships" + ) + + instructions = "Convert all responses into valid JSON output." + + if not self._is_llama(self.llm): + model_schema = PydanticSchemaParser(model=TaskEvaluation).get_schema() + instructions = f"{instructions}\n\nReturn only valid JSON with the following schema:\n```json\n{model_schema}\n```" + + converter = Converter( + llm=self.llm, + text=evaluation_query, + model=TaskEvaluation, + instructions=instructions, + ) + + return converter.to_pydantic() + + def _is_llama(self, llm) -> bool: + return isinstance(llm, ChatGroq) and llm.groq_api_base is None + + def evaluate_training_data( + self, training_data: dict, agent_id: str + ) -> TrainingTaskEvaluation: + """ + Evaluate the training data based on the llm output, human feedback, and improved output. + + Parameters: + - training_data (dict): The training data to be evaluated. + - agent_id (str): The ID of the agent. + """ + + output_training_data = training_data[agent_id] + + final_aggregated_data = "" + for _, data in output_training_data.items(): + final_aggregated_data += ( + f"Initial Output:\n{data['initial_output']}\n\n" + f"Human Feedback:\n{data['human_feedback']}\n\n" + f"Improved Output:\n{data['improved_output']}\n\n" + ) + + evaluation_query = ( + "Assess the quality of the training data based on the llm output, human feedback , and llm output improved result.\n\n" + f"{final_aggregated_data}" + "Please provide:\n" + "- Based on the Human Feedbacks and the comparison between Initial Outputs and Improved outputs provide action items based on human_feedback for future tasks\n" + "- A score from 0 to 10 evaluating on completion, quality, and overall performance from the improved output to the initial output based on the human feedback\n" + ) + instructions = "I'm gonna convert this raw text into valid JSON." + + if not self._is_llama(self.llm): + model_schema = PydanticSchemaParser( + model=TrainingTaskEvaluation + ).get_schema() + instructions = f"{instructions}\n\nThe json should have the following structure, with the following keys:\n{model_schema}" + + converter = Converter( + llm=self.llm, + text=evaluation_query, + model=TrainingTaskEvaluation, + instructions=instructions, + ) + + pydantic_result = converter.to_pydantic() + return pydantic_result diff --git a/squadai/utilities/exceptions/context_window_exceeding_exception.py b/squadai/utilities/exceptions/context_window_exceeding_exception.py new file mode 100644 index 0000000..c02c40c --- /dev/null +++ b/squadai/utilities/exceptions/context_window_exceeding_exception.py @@ -0,0 +1,26 @@ +class LLMContextLengthExceededException(Exception): + CONTEXT_LIMIT_ERRORS = [ + "maximum context length", + "context length exceeded", + "context_length_exceeded", + "context window full", + "too many tokens", + "input is too long", + "exceeds token limit", + ] + + def __init__(self, error_message: str): + self.original_error_message = error_message + super().__init__(self._get_error_message(error_message)) + + def _is_context_limit_error(self, error_message: str) -> bool: + return any( + phrase.lower() in error_message.lower() + for phrase in self.CONTEXT_LIMIT_ERRORS + ) + + def _get_error_message(self, error_message: str): + return ( + f"LLM context length exceeded. Original error: {error_message}\n" + "Consider using a smaller input or implementing a text splitting strategy." + ) diff --git a/squadai/utilities/file_handler.py b/squadai/utilities/file_handler.py new file mode 100644 index 0000000..1125cae --- /dev/null +++ b/squadai/utilities/file_handler.py @@ -0,0 +1,70 @@ +import os +import pickle +from datetime import datetime + + +class FileHandler: + """take care of file operations, currently it only logs messages to a file""" + + def __init__(self, file_path): + if isinstance(file_path, bool): + self._path = os.path.join(os.curdir, "logs.txt") + elif isinstance(file_path, str): + self._path = file_path + else: + raise ValueError("file_path must be either a boolean or a string.") + + def log(self, **kwargs): + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + message = f"{now}: ".join([f"{key}={value}" for key, value in kwargs.items()]) + with open(self._path, "a", encoding="utf-8") as file: + file.write(message + "\n") + + +class PickleHandler: + def __init__(self, file_name: str) -> None: + """ + Initialize the PickleHandler with the name of the file where data will be stored. + The file will be saved in the current directory. + + Parameters: + - file_name (str): The name of the file for saving and loading data. + """ + if not file_name.endswith(".pkl"): + file_name += ".pkl" + + self.file_path = os.path.join(os.getcwd(), file_name) + + def initialize_file(self) -> None: + """ + Initialize the file with an empty dictionary and overwrite any existing data. + """ + self.save({}) + + def save(self, data) -> None: + """ + Save the data to the specified file using pickle. + + Parameters: + - data (object): The data to be saved. + """ + with open(self.file_path, "wb") as file: + pickle.dump(data, file) + + def load(self) -> dict: + """ + Load the data from the specified file using pickle. + + Returns: + - dict: The data loaded from the file. + """ + if not os.path.exists(self.file_path) or os.path.getsize(self.file_path) == 0: + return {} # Return an empty dictionary if the file does not exist or is empty + + with open(self.file_path, "rb") as file: + try: + return pickle.load(file) + except EOFError: + return {} # Return an empty dictionary if the file is empty or corrupted + except Exception: + raise # Raise any other exceptions that occur during loading diff --git a/squadai/utilities/formatter.py b/squadai/utilities/formatter.py new file mode 100644 index 0000000..6eea212 --- /dev/null +++ b/squadai/utilities/formatter.py @@ -0,0 +1,20 @@ +from typing import List + +from squadai.task import Task +from squadai.tasks.task_output import TaskOutput + + +def aggregate_raw_outputs_from_task_outputs(task_outputs: List[TaskOutput]) -> str: + """Generate string context from the task outputs.""" + dividers = "\n\n----------\n\n" + + # Join task outputs with dividers + context = dividers.join(output.raw for output in task_outputs) + return context + + +def aggregate_raw_outputs_from_tasks(tasks: List[Task]) -> str: + """Generate string context from the tasks.""" + task_outputs = [task.output for task in tasks if task.output is not None] + + return aggregate_raw_outputs_from_task_outputs(task_outputs) diff --git a/squadai/utilities/i18n.py b/squadai/utilities/i18n.py new file mode 100644 index 0000000..b283f57 --- /dev/null +++ b/squadai/utilities/i18n.py @@ -0,0 +1,51 @@ +import json +import os +from typing import Dict, Optional + +from pydantic import BaseModel, Field, PrivateAttr, model_validator + + +class I18N(BaseModel): + _prompts: Dict[str, Dict[str, str]] = PrivateAttr() + prompt_file: Optional[str] = Field( + default=None, + description="Path to the prompt_file file to load", + ) + + @model_validator(mode="after") + def load_prompts(self) -> "I18N": + """Load prompts from a JSON file.""" + try: + if self.prompt_file: + with open(self.prompt_file, "r") as f: + self._prompts = json.load(f) + else: + dir_path = os.path.dirname(os.path.realpath(__file__)) + prompts_path = os.path.join(dir_path, "../translations/en.json") + + with open(prompts_path, "r") as f: + self._prompts = json.load(f) + except FileNotFoundError: + raise Exception(f"Prompt file '{self.prompt_file}' not found.") + except json.JSONDecodeError: + raise Exception("Error decoding JSON from the prompts file.") + + if not self._prompts: + self._prompts = {} + + return self + + def slice(self, slice: str) -> str: + return self.retrieve("slices", slice) + + def errors(self, error: str) -> str: + return self.retrieve("errors", error) + + def tools(self, error: str) -> str: + return self.retrieve("tools", error) + + def retrieve(self, kind, key) -> str: + try: + return self._prompts[kind][key] + except Exception as _: + raise Exception(f"Prompt for '{kind}':'{key}' not found.") diff --git a/squadai/utilities/instructor.py b/squadai/utilities/instructor.py new file mode 100644 index 0000000..328beb9 --- /dev/null +++ b/squadai/utilities/instructor.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Type + +import instructor +from pydantic import BaseModel, Field, PrivateAttr, model_validator + + +class Instructor(BaseModel): + """Class that wraps an agent llm with instructor.""" + + _client: Any = PrivateAttr() + content: str = Field(description="Content to be sent to the instructor.") + agent: Optional[Any] = Field( + description="The agent that needs to use instructor.", default=None + ) + llm: Optional[Any] = Field( + description="The agent that needs to use instructor.", default=None + ) + instructions: Optional[str] = Field( + description="Instructions to be sent to the instructor.", + default=None, + ) + model: Type[BaseModel] = Field( + description="Pydantic model to be used to create an output." + ) + + @model_validator(mode="after") + def set_instructor(self): + """Set instructor.""" + if self.agent and not self.llm: + self.llm = self.agent.function_calling_llm or self.agent.llm + + self._client = instructor.patch( + self.llm.client._client, + mode=instructor.Mode.TOOLS, + ) + return self + + def to_json(self): + model = self.to_pydantic() + return model.model_dump_json(indent=2) + + def to_pydantic(self): + messages = [{"role": "user", "content": self.content}] + if self.instructions: + messages.append({"role": "system", "content": self.instructions}) + + model = self._client.chat.completions.create( + model=self.llm.model_name, response_model=self.model, messages=messages + ) + return model diff --git a/squadai/utilities/logger.py b/squadai/utilities/logger.py new file mode 100644 index 0000000..d07301f --- /dev/null +++ b/squadai/utilities/logger.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, PrivateAttr + +from squadai.utilities.printer import Printer + + +class Logger(BaseModel): + verbose: bool = Field(default=False) + _printer: Printer = PrivateAttr(default_factory=Printer) + + def log(self, level, message, color="bold_green"): + if self.verbose: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._printer.print( + f"[{timestamp}][{level.upper()}]: {message}", color=color + ) diff --git a/squadai/utilities/parser.py b/squadai/utilities/parser.py new file mode 100644 index 0000000..c19cc11 --- /dev/null +++ b/squadai/utilities/parser.py @@ -0,0 +1,31 @@ +import re + + +class YamlParser: + @staticmethod + def parse(file): + """ + Parses a YAML file, modifies specific patterns, and checks for unsupported 'context' usage. + Args: + file (file object): The YAML file to parse. + Returns: + str: The modified content of the YAML file. + Raises: + ValueError: If 'context:' is used incorrectly. + """ + content = file.read() + + # Replace single { and } with doubled ones, while leaving already doubled ones intact and the other special characters {# and {% + modified_content = re.sub(r"(? PlannerTaskPydanticOutput: + """Handles the Squad planning by creating detailed step-by-step plans for each task.""" + planning_agent = self._create_planning_agent() + tasks_summary = self._create_tasks_summary() + + planner_task = self._create_planner_task(planning_agent, tasks_summary) + + result = planner_task.execute_sync() + + if isinstance(result.pydantic, PlannerTaskPydanticOutput): + return result.pydantic + + raise ValueError("Failed to get the Planning output") + + def _create_planning_agent(self) -> Agent: + """Creates the planning agent for the squad planning.""" + return Agent( + role="Task Execution Planner", + goal=( + "Your goal is to create an extremely detailed, step-by-step plan based on the tasks and tools " + "available to each agent so that they can perform the tasks in an exemplary manner" + ), + backstory="Planner agent for squad planning", + llm=self.planning_agent_llm, + ) + + def _create_planner_task(self, planning_agent: Agent, tasks_summary: str) -> Task: + """Creates the planner task using the given agent and tasks summary.""" + return Task( + description=( + f"Based on these tasks summary: {tasks_summary} \n Create the most descriptive plan based on the tasks " + "descriptions, tools available, and agents' goals for them to execute their goals with perfection." + ), + expected_output="Step by step plan on how the agents can execute their tasks using the available tools with mastery", + agent=planning_agent, + output_pydantic=PlannerTaskPydanticOutput, + ) + + def _create_tasks_summary(self) -> str: + """Creates a summary of all tasks.""" + tasks_summary = [] + for idx, task in enumerate(self.tasks): + tasks_summary.append( + f""" + Task Number {idx + 1} - {task.description} + "task_description": {task.description} + "task_expected_output": {task.expected_output} + "agent": {task.agent.role if task.agent else "None"} + "agent_goal": {task.agent.goal if task.agent else "None"} + "task_tools": {task.tools} + "agent_tools": {task.agent.tools if task.agent else "None"} + """ + ) + return " ".join(tasks_summary) diff --git a/squadai/utilities/printer.py b/squadai/utilities/printer.py new file mode 100644 index 0000000..4bdd585 --- /dev/null +++ b/squadai/utilities/printer.py @@ -0,0 +1,34 @@ +class Printer: + def print(self, content: str, color: str): + if color == "purple": + self._print_purple(content) + elif color == "red": + self._print_red(content) + elif color == "bold_green": + self._print_bold_green(content) + elif color == "bold_purple": + self._print_bold_purple(content) + elif color == "bold_blue": + self._print_bold_blue(content) + elif color == "yellow": + self._print_yellow(content) + else: + print(content) + + def _print_bold_purple(self, content): + print("\033[1m\033[95m {}\033[00m".format(content)) + + def _print_bold_green(self, content): + print("\033[1m\033[92m {}\033[00m".format(content)) + + def _print_purple(self, content): + print("\033[95m {}\033[00m".format(content)) + + def _print_red(self, content): + print("\033[91m {}\033[00m".format(content)) + + def _print_bold_blue(self, content): + print("\033[1m\033[94m {}\033[00m".format(content)) + + def _print_yellow(self, content): + print("\033[93m {}\033[00m".format(content)) diff --git a/squadai/utilities/prompts.py b/squadai/utilities/prompts.py new file mode 100644 index 0000000..865f4ff --- /dev/null +++ b/squadai/utilities/prompts.py @@ -0,0 +1,64 @@ +from typing import Any, ClassVar, Optional + +from langchain.prompts import BasePromptTemplate, PromptTemplate +from pydantic import BaseModel, Field + +from squadai.utilities import I18N + + +class Prompts(BaseModel): + """Manages and generates prompts for a generic agent.""" + + i18n: I18N = Field(default=I18N()) + tools: list[Any] = Field(default=[]) + system_template: Optional[str] = None + prompt_template: Optional[str] = None + response_template: Optional[str] = None + SCRATCHPAD_SLICE: ClassVar[str] = "\n{agent_scratchpad}" + + def task_execution(self) -> BasePromptTemplate: + """Generate a standard prompt for task execution.""" + slices = ["role_playing"] + if len(self.tools) > 0: + slices.append("tools") + else: + slices.append("no_tools") + + slices.append("task") + + if not self.system_template and not self.prompt_template: + return self._build_prompt(slices) + else: + return self._build_prompt( + slices, + self.system_template, + self.prompt_template, + self.response_template, + ) + + def _build_prompt( + self, + components: list[str], + system_template=None, + prompt_template=None, + response_template=None, + ) -> BasePromptTemplate: + """Constructs a prompt string from specified components.""" + if not system_template and not prompt_template: + prompt_parts = [self.i18n.slice(component) for component in components] + prompt_parts.append(self.SCRATCHPAD_SLICE) + prompt = PromptTemplate.from_template("".join(prompt_parts)) + else: + prompt_parts = [ + self.i18n.slice(component) + for component in components + if component != "task" + ] + system = system_template.replace("{{ .System }}", "".join(prompt_parts)) + prompt = prompt_template.replace( + "{{ .Prompt }}", + "".join([self.i18n.slice("task"), self.SCRATCHPAD_SLICE]), + ) + response = response_template.split("{{ .Response }}")[0] + prompt = PromptTemplate.from_template(f"{system}\n{prompt}\n{response}") + return prompt diff --git a/squadai/utilities/pydantic_schema_parser.py b/squadai/utilities/pydantic_schema_parser.py new file mode 100644 index 0000000..9d9cdab --- /dev/null +++ b/squadai/utilities/pydantic_schema_parser.py @@ -0,0 +1,42 @@ +from typing import Type, get_args, get_origin + +from pydantic import BaseModel + + +class PydanticSchemaParser(BaseModel): + model: Type[BaseModel] + + def get_schema(self) -> str: + """ + Public method to get the schema of a Pydantic model. + + :param model: The Pydantic model class to generate schema for. + :return: String representation of the model schema. + """ + return self._get_model_schema(self.model) + + def _get_model_schema(self, model, depth=0) -> str: + indent = " " * depth + lines = [f"{indent}{{"] + for field_name, field in model.model_fields.items(): + field_type_str = self._get_field_type(field, depth + 1) + lines.append(f"{indent} {field_name}: {field_type_str},") + lines[-1] = lines[-1].rstrip(",") # Remove trailing comma from last item + lines.append(f"{indent}}}") + return "\n".join(lines) + + def _get_field_type(self, field, depth) -> str: + field_type = field.annotation + if get_origin(field_type) is list: + list_item_type = get_args(field_type)[0] + if isinstance(list_item_type, type) and issubclass( + list_item_type, BaseModel + ): + nested_schema = self._get_model_schema(list_item_type, depth + 1) + return f"List[\n{nested_schema}\n{' ' * 4 * depth}]" + else: + return f"List[{list_item_type.__name__}]" + elif issubclass(field_type, BaseModel): + return self._get_model_schema(field_type, depth) + else: + return field_type.__name__ diff --git a/squadai/utilities/rpm_controller.py b/squadai/utilities/rpm_controller.py new file mode 100644 index 0000000..fbd0072 --- /dev/null +++ b/squadai/utilities/rpm_controller.py @@ -0,0 +1,73 @@ +import threading +import time +from typing import Optional + +from pydantic import BaseModel, Field, PrivateAttr, model_validator + +from squadai.utilities.logger import Logger + + +class RPMController(BaseModel): + max_rpm: Optional[int] = Field(default=None) + logger: Logger = Field(default_factory=lambda: Logger(verbose=False)) + _current_rpm: int = PrivateAttr(default=0) + _timer: Optional[threading.Timer] = PrivateAttr(default=None) + _lock: Optional[threading.Lock] = PrivateAttr(default=None) + _shutdown_flag: bool = PrivateAttr(default=False) + + @model_validator(mode="after") + def reset_counter(self): + if self.max_rpm is not None: + if not self._shutdown_flag: + self._lock = threading.Lock() + self._reset_request_count() + return self + + def check_or_wait(self): + if self.max_rpm is None: + return True + + def _check_and_increment(): + if self.max_rpm is not None and self._current_rpm < self.max_rpm: + self._current_rpm += 1 + return True + elif self.max_rpm is not None: + self.logger.log( + "info", "Max RPM reached, waiting for next minute to start." + ) + self._wait_for_next_minute() + self._current_rpm = 1 + return True + return True + + if self._lock: + with self._lock: + return _check_and_increment() + else: + return _check_and_increment() + + def stop_rpm_counter(self): + if self._timer: + self._timer.cancel() + self._timer = None + + def _wait_for_next_minute(self): + time.sleep(60) + self._current_rpm = 0 + + def _reset_request_count(self): + def _reset(): + self._current_rpm = 0 + if not self._shutdown_flag: + self._timer = threading.Timer(60.0, self._reset_request_count) + self._timer.start() + + if self._lock: + with self._lock: + _reset() + else: + _reset() + + if self._timer: + self._shutdown_flag = True + self._timer.cancel() diff --git a/squadai/utilities/squad_json_encoder.py b/squadai/utilities/squad_json_encoder.py new file mode 100644 index 0000000..8fdf8a8 --- /dev/null +++ b/squadai/utilities/squad_json_encoder.py @@ -0,0 +1,31 @@ +from datetime import datetime +import json +from uuid import UUID +from pydantic import BaseModel + + +class SquadJSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, BaseModel): + return self._handle_pydantic_model(obj) + elif isinstance(obj, UUID): + return str(obj) + + elif isinstance(obj, datetime): + return obj.isoformat() + return super().default(obj) + + def _handle_pydantic_model(self, obj): + try: + data = obj.model_dump() + # Remove circular references + for key, value in data.items(): + if isinstance(value, BaseModel): + data[key] = str( + value + ) # Convert nested models to string representation + return data + except RecursionError: + return str( + obj + ) # Fall back to string representation if circular reference is detected diff --git a/squadai/utilities/squad_pydantic_output_parser.py b/squadai/utilities/squad_pydantic_output_parser.py new file mode 100644 index 0000000..ade8384 --- /dev/null +++ b/squadai/utilities/squad_pydantic_output_parser.py @@ -0,0 +1,48 @@ +import json +from typing import Any, List, Type + +import regex +from langchain.output_parsers import PydanticOutputParser +from langchain_core.exceptions import OutputParserException +from langchain_core.outputs import Generation +from langchain_core.pydantic_v1 import ValidationError +from pydantic import BaseModel + + +class SquadPydanticOutputParser(PydanticOutputParser): + """Parses the text into pydantic models""" + + pydantic_object: Type[BaseModel] + + def parse_result(self, result: List[Generation]) -> Any: + result[0].text = self._transform_in_valid_json(result[0].text) + + # Treating edge case of function calling llm returning the name instead of tool_name + json_object = json.loads(result[0].text) + if "tool_name" not in json_object: + json_object["tool_name"] = json_object.get("name", "") + result[0].text = json.dumps(json_object) + + try: + return self.pydantic_object.model_validate(json_object) + except ValidationError as e: + name = self.pydantic_object.__name__ + msg = f"Failed to parse {name} from completion {json_object}. Got: {e}" + raise OutputParserException(msg, llm_output=json_object) + + def _transform_in_valid_json(self, text) -> str: + text = text.replace("```", "").replace("json", "") + json_pattern = r"\{(?:[^{}]|(?R))*\}" + matches = regex.finditer(json_pattern, text) + + for match in matches: + try: + # Attempt to parse the matched string as JSON + json_obj = json.loads(match.group()) + # Return the first successfully parsed JSON object + json_obj = json.dumps(json_obj) + return str(json_obj) + except json.JSONDecodeError: + # If parsing fails, skip to the next match + continue + return text diff --git a/squadai/utilities/task_output_storage_handler.py b/squadai/utilities/task_output_storage_handler.py new file mode 100644 index 0000000..1115968 --- /dev/null +++ b/squadai/utilities/task_output_storage_handler.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Dict, Any, Optional, List +from squadai.memory.storage.kickoff_task_outputs_storage import ( + KickoffTaskOutputsSQLiteStorage, +) +from squadai.task import Task + + +class ExecutionLog(BaseModel): + task_id: str + expected_output: Optional[str] = None + output: Dict[str, Any] + timestamp: datetime = Field(default_factory=datetime.now) + task_index: int + inputs: Dict[str, Any] = Field(default_factory=dict) + was_replayed: bool = False + + def __getitem__(self, key: str) -> Any: + return getattr(self, key) + + +class TaskOutputStorageHandler: + def __init__(self) -> None: + self.storage = KickoffTaskOutputsSQLiteStorage() + + def update(self, task_index: int, log: Dict[str, Any]): + saved_outputs = self.load() + if saved_outputs is None: + raise ValueError("Logs cannot be None") + + if log.get("was_replayed", False): + replayed = { + "task_id": str(log["task"].id), + "expected_output": log["task"].expected_output, + "output": log["output"], + "was_replayed": log["was_replayed"], + "inputs": log["inputs"], + } + self.storage.update( + task_index, + **replayed, + ) + else: + self.storage.add(**log) + + def add( + self, + task: Task, + output: Dict[str, Any], + task_index: int, + inputs: Dict[str, Any] = {}, + was_replayed: bool = False, + ): + self.storage.add(task, output, task_index, was_replayed, inputs) + + def reset(self): + self.storage.delete_all() + + def load(self) -> Optional[List[Dict[str, Any]]]: + return self.storage.load() diff --git a/squadai/utilities/token_counter_callback.py b/squadai/utilities/token_counter_callback.py new file mode 100644 index 0000000..d54463b --- /dev/null +++ b/squadai/utilities/token_counter_callback.py @@ -0,0 +1,36 @@ +from typing import Any, Dict, List + +import tiktoken +from langchain.callbacks.base import BaseCallbackHandler +from langchain.schema import LLMResult + +from squadai.agents.agent_builder.utilities.base_token_process import TokenProcess + + +class TokenCalcHandler(BaseCallbackHandler): + model_name: str = "" + token_cost_process: TokenProcess + encoding: tiktoken.Encoding + + def __init__(self, model_name, token_cost_process): + self.model_name = model_name + self.token_cost_process = token_cost_process + try: + self.encoding = tiktoken.encoding_for_model(self.model_name) + except KeyError: + self.encoding = tiktoken.get_encoding("cl100k_base") + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + if self.token_cost_process is None: + return + + for prompt in prompts: + self.token_cost_process.sum_prompt_tokens(len(self.encoding.encode(prompt))) + + async def on_llm_new_token(self, token: str, **kwargs) -> None: + self.token_cost_process.sum_completion_tokens(1) + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + self.token_cost_process.sum_successful_requests(1) diff --git a/squadai/utilities/training_handler.py b/squadai/utilities/training_handler.py new file mode 100644 index 0000000..9364c97 --- /dev/null +++ b/squadai/utilities/training_handler.py @@ -0,0 +1,31 @@ +from squadai.utilities.file_handler import PickleHandler + + +class SquadTrainingHandler(PickleHandler): + def save_trained_data(self, agent_id: str, trained_data: dict) -> None: + """ + Save the trained data for a specific agent. + + Parameters: + - agent_id (str): The ID of the agent. + - trained_data (dict): The trained data to be saved. + """ + data = self.load() + data[agent_id] = trained_data + self.save(data) + + def append(self, train_iteration: int, agent_id: str, new_data) -> None: + """ + Append new data to the existing pickle file. + + Parameters: + - new_data (object): The new data to be appended. + """ + data = self.load() + + if agent_id in data: + data[agent_id][train_iteration] = new_data + else: + data[agent_id] = {train_iteration: new_data} + + self.save(data) diff --git a/system_message.txt b/system_message.txt new file mode 100644 index 0000000..c84d25c --- /dev/null +++ b/system_message.txt @@ -0,0 +1,50 @@ + You are an AI assistant capable of designing configurations for squadAI, an autonomous agent program. Given a user's goal, + create a JSON configuration for a squadAI setup including agents, tasks, and squad details. Respond only with the JSON and nothing else. Make sure you be specific with the user's query or request amd include this in the configuration. + Follow this structure: + { + "agents": [ + { + "role": "...", + "goal": "...", + "backstory": "...", + "verbose": true, + "allow_delegation": true/false, + "tools": ["tool1", "tool2", ...] + }, + ... + ], + "tasks": [ + { + "description": "...", + "expected_output": "...", + "agent": "role of the agent responsible" + }, + ... + ], + "squads": [ + { + "name": "...", + "agents": ["agent_role1", "agent_role2", ...], + "tasks": ["task1", "task2", ...], + "process": "sequential" or "hierarchical", + "verbose": true + }, + ... + ] + } + The list of tools at your disposal along with their descriptions is as follows: + + duckduckgo_tool: A duckduckgo internet search tool. + wikipedia_tool: A tool for accessing the Wikipedia API. + wolframalpha_tool: A tool for accessing the Wolfram Alpha API. + write_file_tool: A tool for writing files. + read_file_tool: A tool for reading files. + list_directory_tool: A tool for listing the contents of the directory. + copy_file_tool: A tool for copying files. + delete_file_tool: A tool for deleting files. + file_search_tool: A tool for searching files. + move_file_tool: A tool for moving files. + scrape_tool: A tool for scraping websites. + You can access any or all of these tools as needed to complete the task you are given. + + diff --git a/tool_reg/__init__.py b/tool_reg/__init__.py new file mode 100644 index 0000000..c1e13cb --- /dev/null +++ b/tool_reg/__init__.py @@ -0,0 +1,27 @@ +# tool_reg/__init__.py +from typing import Dict, Callable +from langchain_community.tools import BaseTool + +class ToolRegistry: + def __init__(self): + self._tools: Dict[str, Callable[[], BaseTool]] = {} + self._initialized_tools: Dict[str, BaseTool] = {} + + def register(self, name: str, initializer: Callable[[], BaseTool]): + self._tools[name] = initializer + + def get(self, name: str) -> BaseTool: + if name not in self._initialized_tools: + if name not in self._tools: + raise KeyError(f"Tool '{name}' not found in registry") + self._initialized_tools[name] = self._tools[name]() + return self._initialized_tools[name] + + def get_all(self) -> Dict[str, BaseTool]: + for name in self._tools: + if name not in self._initialized_tools: + self._initialized_tools[name] = self._tools[name]() + return self._initialized_tools + +# Create a singleton instance of ToolRegistry +tool_registry = ToolRegistry() diff --git a/tool_reg/file_tools.py b/tool_reg/file_tools.py new file mode 100644 index 0000000..999f608 --- /dev/null +++ b/tool_reg/file_tools.py @@ -0,0 +1,49 @@ +# tool_reg/file_tools.py + +import os +from . import tool_registry # Import the singleton instance +from langchain_community.tools.file_management import ( + ReadFileTool, + WriteFileTool, + ListDirectoryTool, + CopyFileTool, + DeleteFileTool, + MoveFileTool, + FileSearchTool +) + +# Define the root directory and workspace +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Go up one level from tool_reg +WORKSPACE_DIR = os.path.join(ROOT_DIR, "Workspace") + +# Ensure the Workspace directory exists +os.makedirs(WORKSPACE_DIR, exist_ok=True) + +def init_read_file(): + return ReadFileTool(root_dir=WORKSPACE_DIR, description="Read the contents of a file in the Workspace directory.") + +def init_write_file(): + return WriteFileTool(root_dir=WORKSPACE_DIR, description="Write content to a file in the Workspace directory.") + +def init_list_directory(): + return ListDirectoryTool(root_dir=WORKSPACE_DIR, description="List files and directories in the Workspace directory.") + +def init_copy_file(): + return CopyFileTool(root_dir=WORKSPACE_DIR, description="Copy a file within the Workspace directory.") + +def init_delete_file(): + return DeleteFileTool(root_dir=WORKSPACE_DIR, description="Delete a file from the Workspace directory.") + +def init_file_search(): + return FileSearchTool(root_dir=WORKSPACE_DIR, description="Search for files in the Workspace directory.") + +def init_move_file(): + return MoveFileTool(root_dir=WORKSPACE_DIR, description="Move a file within the Workspace directory.") + +tool_registry.register("read_file", init_read_file) +tool_registry.register("write_file", init_write_file) +tool_registry.register("list_directory", init_list_directory) +tool_registry.register("copy_file", init_copy_file) +tool_registry.register("delete_file", init_delete_file) +tool_registry.register("file_search", init_file_search) +tool_registry.register("move_file", init_move_file) diff --git a/tool_reg/info_tools.py b/tool_reg/info_tools.py new file mode 100644 index 0000000..d79fd16 --- /dev/null +++ b/tool_reg/info_tools.py @@ -0,0 +1,14 @@ +# tool_reg/info_tools.py + +from . import tool_registry # Import the singleton instance +from langchain_community.tools import WolframAlphaQueryRun +from langchain_community.utilities import WolframAlphaAPIWrapper +from dotenv import load_dotenv + +load_dotenv() + +def init_wolframalpha(): + wolframalpha_api = WolframAlphaAPIWrapper() + return WolframAlphaQueryRun(api_wrapper=wolframalpha_api) + +tool_registry.register("wolframalpha", init_wolframalpha) diff --git a/tool_reg/scrape_tools.py b/tool_reg/scrape_tools.py new file mode 100644 index 0000000..023aaf9 --- /dev/null +++ b/tool_reg/scrape_tools.py @@ -0,0 +1,61 @@ +from . import tool_registry # Import the singleton instance + +import os +import json +from squadai.agent import Agent +from squadai.task import Task +from squadai.squad import Squad +from langchain.tools import tool +from langchain_groq import ChatGroq +from dotenv import load_dotenv +from firecrawl.firecrawl import FirecrawlApp + +load_dotenv() + +groq_api_key = os.getenv("GROQ_API_KEY") +firecrawl_api_key = os.getenv("FIRECRAWL_API_KEY") +llm = ChatGroq(api_key=groq_api_key, model="llama-3.1-70b-versatile") +firecrawl_app = FirecrawlApp(api_key=firecrawl_api_key) + +class BrowserTools(): + + @tool("Scrape website content") + def scrape_and_summarize_website(website): + """Useful to scrape and summarize website content.""" + + # Fetch the website content using Firecrawl + scrape_result = firecrawl_app.scrape_url(website) + if not scrape_result or 'markdown' not in scrape_result: + return f"Failed to retrieve content from {website}" + + content = scrape_result['markdown'] + + # Split the content into chunks if it's too long + content_chunks = [content[i:i + 8000] for i in range(0, len(content), 8000)] + summaries = [] + + for chunk in content_chunks: + # Define the agent and task + agent = Agent( + role='Principal Researcher', + goal='Do amazing research and summaries based on the content you are working with', + backstory="You're a Principal Researcher at a big company and you need to do research about a given topic.", + allow_delegation=False, + llm=llm + ) + task = Task( + agent=agent, + description=f'Analyze and summarize the content below, make sure to include the most relevant information in the summary. Return only the summary, nothing else.\n\nCONTENT\n----------\n{chunk}', + expected_output="Analysis and summary of scraped web content." + ) + # Execute the task and get the summary + squad = Squad( + agents=[agent], + tasks=[task], + verbose=False + ) + summary = str(squad.kickoff()) + summaries.append(summary) + + return "\n\n".join(summaries) + diff --git a/tool_reg/search_tools.py b/tool_reg/search_tools.py new file mode 100644 index 0000000..939f1eb --- /dev/null +++ b/tool_reg/search_tools.py @@ -0,0 +1,15 @@ +# tool_reg/search_tools.py + +from . import tool_registry # Import the singleton instance +from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun +from langchain_community.utilities import WikipediaAPIWrapper + +def init_duckduckgo(): + return DuckDuckGoSearchRun() + +def init_wikipedia(): + wikipedia_api = WikipediaAPIWrapper() + return WikipediaQueryRun(api_wrapper=wikipedia_api) + +tool_registry.register("duckduckgo", init_duckduckgo) +tool_registry.register("wikipedia", init_wikipedia)